Homepage
Hello, world!
My website is organized like a book. You can see the table of contents on the left. You can click on what interests you or use the arrows on the sides of the page to navigate.
Currently the only thing here is my Tak Bot Tutorial.
Tak Bot Tutorial
Let's make an engine (AKA bot) that plays Tak!
The tutorial is organized into bite-sized chapters that build on each other:
- Explore how we Represent Tak inside our code.
- Build a command-line interface to Play Tak Locally.
- Implement a simple heuristic agent using Move Ordering.
- Add your bot to PlayTak in Talking to PlayTak.
- Make the engine smarter by thinking with Tree Search.
Prerequisites
You will need to know:
- Fundamentals of the Python programming language
- Rules of Tak (you can try it on PlayTak or ptn-ninja)
Python is a Plastic Fork
We will be using Python for this tutorial because it is a popular programming language and easy to learn. Unfortunately, it can be quite slow, so once performance becomes a problem, consider switching to faster language.
Many people in the Tak community made their bots in Rust, so there are plenty of examples as well as libraries to help you out in case you choose to switch later.
Other Resources
The Chess Programming Wiki is an excellent resource for creating engines that play board games (not just chess).
Representing Tak
When implementing a bot for a board game, one would usually start by making an implementation of the game.
This is a fun challenge, but it is not a goal for this tutorial. For this reason, we will be using takpy
,
a library I prepared earlier, which includes a complete implementation of Tak. This means it has a state
representation and the move generation. It does not include a graphical user interface
(we will eventually be using PlayTak for that).
takpy
was actually not written in Python. It's a wrapper around my Rust library [fast-tak
] which I wrote for my AlphaZero bots WilemBot and TakZero. If you have some feedback about thetakpy
interface, let me know on Discord or GitHub.
In this chapter I'll describe how we represent a position in the game, what sort of types there are, and how to use the library to play a game of Tak.
Installation
Assuming you have a recent version of Python 3 installed, you can install takpy
using pip
:
pip install takpy
Sometimes the Python executable and PIP are instead named
python3
andpip3
respectively. You can try using those if my commands don't work for you.
Once that is done, start up your Python REPL and follow along with the examples:
> python
Python 3.12.5 (tags/v3.12.5:ff3bc82, Aug 6 2024, 20:45:27) [MSC v.1940 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
Creating a New Game
To create a new game use new_game
.
>>> from takpy import new_game
>>> # Create a 5x5 game.
>>> new_game(5)
x5/x5/x5/x5/x5 1 1
>>> # Create a 6x6 game with 2 komi (4 half-komi).
>>> new_game(6, half_komi=4)
x6/x6/x6/x6/x6/x6 1 1
To make a game from TPS use game_from_tps
.
>>> from takpy import game_from_tps
>>> # Create a 4x4 game from TPS.
>>> game_from_tps(4, "12,1,1,1/2112,x3/1S,x3/2,x3 2 8")
12,1,1,1/2112,x3/1S,x3/2,x3 2 8
>>> # Create a 5x5 game from TPS with 2 komi.
>>> game_from_tps(5, "2,x4/x,1,x3/x2,1,x2/x3,2,x/x4,1 2 3", half_komi=4)
2,x4/x,1,x3/x2,1,x2/x3,2,x/x4,1 2 3
Using half-komi is admittedly a little confusing, but it allows for fractional komi without needing to use floating point numbers. A komi of
2.5
would be represented by5
half-komi.
The Board and Stacks
Each square on an NxN Tak board can contain a very high stack, only limited by the number of reserves available.
We represent the board as a nested list: The outer lits contains rows, and the rows are lists of squares.
A square is either empty (represented by None
), or it contains a stack. A stack is a top Piece
and
a list of colors for every piece in the stack from bottom to top.
An empty 5x5 board would look like this:
>>> game = new_game(5)
>>> game
x5/x5/x5/x5/x5 1 1
>>> game.board()
[
[None, None, None, None, None],
[None, None, None, None, None],
[None, None, None, None, None],
[None, None, None, None, None],
[None, None, None, None, None]
]
If you are following along (as you should be!) then the board representation is likely not nicely aligned like in the above example. I formatted it nicely here for clarity.
If we play a1
, we get this board:
>>> from takpy import Move
>>> game.play(Move("a1"))
>>> game
x5/x5/x5/x5/2,x4 2 1
>>> game.board()
[
[(Piece.Flat, [Color.Black]), None, None, None, None],
[ None, None, None, None, None],
[ None, None, None, None, None],
[ None, None, None, None, None],
[ None, None, None, None, None]
]
You can see from this example that a1
is the first element in the first row.
Likewise, a2
is the first element in the second row:
>>> game.play(Move("a2"))
>>> game
x5/x5/x5/1,x4/2,x4 1 2
>>> game.board()
[
[(Piece.Flat, [Color.Black]), None, None, None, None],
[(Piece.Flat, [Color.White]), None, None, None, None],
[ None, None, None, None, None],
[ None, None, None, None, None],
[ None, None, None, None, None]
]
Let's take a closer look at how the squares are represented. We will use the position shown in the image below.
>>> game = game_from_tps(4, "x4/x,2122,1122S,x/x,21S,21,x/x4 2 12")
>>> game
x4/x,2122,1122S,x/x,21S,21,x/x4 2 12
>>> game.board()
[
[None, None, None, None],
[None, (Piece.Wall, [Color.Black, Color.White]), (Piece.Flat, [Color.Black, Color.White]), None],
[None, (Piece.Flat, [Color.Black, Color.White, Color.Black, Color.Black]), (Piece.Wall, [Color.White, Color.White, Color.Black, Color.Black]), None],
[None, None, None, None]
]
Probably the first thing you notice is that the rows are in the opposite order: The stacks which appear in the second row from the top in the image actually appear in the third row in the board representation. This is because in the image the ranks increase as we go further up, but in the representation, we want the ranks to increase as we go further in the, which is displayed further down.
At b2
we have a white wall on top of a black flat. This is represented by (Piece.Wall, [Color.Black, Color.White])
in the second element of the second row of the board. Notice that the colors in the stack are stored bottom to top.
>>> board = game.board()
>>> board[1][1] # b2
(Piece.Wall, [Color.Black, Color.White])
The second element in the second row is at
[1][1]
because we start indices from0
.
Since the colors are stored bottom to top, to get the color of the top of the stack,
you would access the color at [-1]
(i.e. the last element).
>>> piece, colors = board[1][1]
>>> colors[-1] # getting the color at the top of the stack
Color.White
Here are all the other stacks:
>>> board[1][2] # c2
(Piece.Flat, [Color.Black, Color.White])
>>> board[2][1] # b3
(Piece.Flat, [Color.Black, Color.White, Color.Black, Color.Black])
>>> board[2][2] # c3
(Piece.Wall, [Color.White, Color.White, Color.Black, Color.Black])
Making Moves
To make a move in the game, you first need to know which moves are possible.
You can use .possible_moves
to get a list of moves that are possible in the current position.
>>> game = new_game(6)
>>> game.possible_moves()
[
a1, a2, a3, a4, a5, a6,
b1, b2, b3, b4, b5, b6,
c1, c2, c3, c4, c5, c6,
d1, d2, d3, d4, d5, d6,
e1, e2, e3, e4, e5, e6,
f1, f2, f3, f4, f5, f6
]
To make a move, use .play(move)
with a valid move.
>>> my_move = game.possible_moves()[3]
>>> my_move
a4
>>> game.play(my_move)
>>> game
x6/x6/2,x5/x6/x6/x6 2 1
>>> game.board()
[
[ None, None, None, None, None, None],
[ None, None, None, None, None, None],
[ None, None, None, None, None, None],
[(Piece.Flat, [Color.Black]), None, None, None, None, None],
[ None, None, None, None, None, None],
[ None, None, None, None, None, None]
]
If you try playing an invalid move, you will get a ValueError
.
>>> game.play(my_move) # try to play `a4` again
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: cannot place a piece in that position because it is already occupied
As we have seen earlier, we can also create moves using their PTN representation:
>>> another_move = Move("a6")
>>> another_move
a6
>>> game.play(another_move)
>>> game
1,x5/x6/2,x5/x6/x6/x6 1 2
>>> game.board()
[
[ None, None, None, None, None, None],
[ None, None, None, None, None, None],
[ None, None, None, None, None, None],
[(Piece.Flat, [Color.Black]), None, None, None, None, None],
[ None, None, None, None, None, None],
[(Piece.Flat, [Color.White]), None, None, None, None, None]
]
Other Useful Features
It's often useful to create a copy of the current position so something can be simulated on the copy without affecting the original.
You can use .clone()
for this:
>>> clone = game.clone()
>>> clone # it's the same as `game`
1,x5/x6/2,x5/x6/x6/x6 1 2
>>> clone.play(Move("Cb2")) # play another move
>>> clone
1,x5/x6/2,x5/x6/x,1C,x4/x6 2 2
>>> clone.board()
[
[ None, None, None, None, None, None],
[ None, (Piece.Cap, [Color.White]), None, None, None, None],
[ None, None, None, None, None, None],
[(Piece.Flat, [Color.Black]), None, None, None, None, None],
[ None, None, None, None, None, None],
[(Piece.Flat, [Color.White]), None, None, None, None, None]
]
>>> game # unaffected
1,x5/x6/2,x5/x6/x6/x6 1 2
>>> game.board()
[
[ None, None, None, None, None, None],
[ None, None, None, None, None, None],
[ None, None, None, None, None, None],
[(Piece.Flat, [Color.Black]), None, None, None, None, None],
[ None, None, None, None, None, None],
[(Piece.Flat, [Color.White]), None, None, None, None, None]
]
This pattern of creating a clone and playing a move is very common.
To make it easier, there exists a convenience method .clone_and_play()
:
>>> game
1,x5/x6/2,x5/x6/x6/x6 1 2
>>> clone = game.clone_and_play(Move("Sd3"))
>>> clone # the clone has `Sd3` on the board
1,x5/x6/2,x5/x3,1S,x2/x6/x6 2 2
>>> game # unaffected
1,x5/x6/2,x5/x6/x6/x6 1 2
The Game
object provides various other information and helper methods:
- the size of the board (
.size
) - the half-komi (
.half_komi
) - whose turn it is (
.to_move
) - reserve counts (
.white_reserves
,.black_reserves
) - how many plies have been played (
.ply
) - the result of the game (
.result()
) - and more!
>>> game.size
6
>>> game.half_komi
0
>>> game.to_move
Color.White
>>> game.white_reserves
(29, 1)
>>> game.black_reserves
(29, 1)
>>> game.ply
2
>>> game.result()
GameResult.Ongoing
The entire available interface can be seen in the takpy.pyi
stub file
unless I forget to update it when I change the underlying code.
Please let me know if you encounter any inconsistencies!
Playing Tak Locally
Although it is not necessary to implement a command-line interface (CLI) to make
a Tak bot, it is a useful exercise to get practice with the [takpy
] package
before we move only more complicated topics. Having a simple CLI will also make
it easy to play with our bot before we set up a connection to PlayTak.
We will create a loop which prompts us to enter moves and have them take effect on a virtual board. We will also build a way to visualize the board that is a little easier to look at than TPS strings.
Move Loop
Before we do anything, make sure takpy
is installed (See Representing Tak).
Then, make a new Python file (name it bot.py
), and open it in your favorite editor (I am using VSCode).
The CLI will consist of a simple loop where the user is presented with the current board state
and they enter a move as PTN to see it happen on the board. We need to initialize the game before we
can modify it, and we have already seen that we can use new_game
for that:
from takpy import new_game
game = new_game(6)
print(game)
Next we create a while
loop that will only exit once the game is over.
We want to keep asking the user for moves, and that ends when there are no more moves to play.
To find the result of a game, we use game.result()
which can be either Ongoing
, WhiteWin
, BlackWin
, or Draw
.
from takpy import new_game, GameResult
game = new_game(6)
while game.result() == GameResult.Ongoing:
print(game)
Press Ctrl + C while the terminal is selected to interrupt and exit a program.
If you try to run the code above, you will quickly notice that the program is stuck in an infinite loop.
This is because we are not changing the game state in the loop, so the result will never change from
GameResult.Ongoing
. Let's fix that by asking the user to give us a move and playing that.
from takpy import new_game, GameResult, Move
game = new_game(6)
while game.result() == GameResult.Ongoing:
print(game)
user_input = input("enter move: ")
move = Move(user_input)
game.play(move)
Now we can actually play a game!
Handling errors
If you try running the code above you might see something like this:
x6/x6/x6/x6/x6/x6 1 1
enter move: a6
2,x5/x6/x6/x6/x6/x6 2 1
enter move: f1
2,x5/x6/x6/x6/x6/x5,1 1 2
enter move: e3
2,x5/x6/x6/x4,1,x/x6/x5,1 2 2
enter move: c4
2,x5/x6/x2,2,x3/x4,1,x/x6/x5,1 1 3
enter move: hello
Traceback (most recent call last):
File "D:\Code\takbot-tutorial\part_1\bot.py", line 74, in <module>
move = Move(user_input)
^^^^^^^^^^^^^^^^
ValueError: move prefix was not a valid piece or count
The program crashed when I put in an invalid move hello
.
This is because Move
will raise a ValueError
when the move is not valid PTN.
We can handle this error with a try-except
block.
from takpy import new_game, GameResult, Move
game = new_game(6)
while game.result() == GameResult.Ongoing:
print(game)
user_input = input("enter move: ")
try:
move = Move(user_input)
except ValueError as error:
print(f"invalid PTN: {error}")
continue
game.play(move)
Now, when we enter an invalid PTN the execution will enter into the except
block and print the reason for the error.
The continue
statement afterwards will skip the rest of the code in the loop,
since we do not want to play a move when the move is invalid.
x6/x6/x6/x6/x6/x6 1 1
enter move: a6
2,x5/x6/x6/x6/x6/x6 2 1
enter move: f1
2,x5/x6/x6/x6/x6/x5,1 1 2
enter move: e3
2,x5/x6/x6/x4,1,x/x6/x5,1 2 2
enter move: hello
invalid PTN: move prefix was not a valid piece or count
2,x5/x6/x6/x4,1,x/x6/x5,1 2 2
enter move: e3<
Traceback (most recent call last):
File "D:\Code\takbot-tutorial\part_1\bot.py", line 79, in <module>
game.play(move)
ValueError: cannot move a stack that you do not own
This time the program didn't crash when I put in hello
, but when I entered a valid PTN move that was invalid in the current board state,
it did crash. This is because game.play
will raise a ValueError
when an impossible move is played. We can handle this similarly.
from takpy import new_game, GameResult, Move
game = new_game(6)
while game.result() == GameResult.Ongoing:
print(game)
user_input = input("enter move: ")
try:
move = Move(user_input)
except ValueError as error:
print(f"invalid PTN: {error}")
continue
try:
game.play(move)
except ValueError as error:
print(f"invalid move: {error}")
Great, the program is no longer crashing on invalid input!
x6/x6/x6/x6/x6/x6 1 1
enter move: a6
2,x5/x6/x6/x6/x6/x6 2 1
enter move: f1
2,x5/x6/x6/x6/x6/x5,1 1 2
enter move: e3
2,x5/x6/x6/x4,1,x/x6/x5,1 2 2
enter move: hello
invalid PTN: move prefix was not a valid piece or count
2,x5/x6/x6/x4,1,x/x6/x5,1 2 2
enter move: e3<
invalid move: cannot move a stack that you do not own
2,x5/x6/x6/x4,1,x/x6/x5,1 2 2
Pretty Printing
Unless you can play Tak blind or are adept are reading TPS, you will quickly get lost in the position after a few moves.
Let's make a simple visualization function that prints the board as a grid. Let's name the function pretty_print
.
It will take an instance of the game, which has a type Game
. We can import this type from takpy
and use it as a type hint
so that our editor can help us find the methods and properties we will need. Let's also import Piece
and Color
since
we will need them later.
from takpy import new_game, GameResult, Move, Game, Piece, Color
def pretty_print(game: Game):
...
game = new_game(6)
while game.result() == GameResult.Ongoing:
pretty_print(game) # Switch out `print` with `pretty_print`.
... # The rest of our code is the same as before.
To keep it simple, let's only print the top of each stack, so that we do not have to think about the 3D nature of Tak. This does mean that the pretty print will be incomplete, so we should also print the TPS so that ambiguities can be resolved. To print the top of each stack, we need to iterate over every square.
def pretty_print(game: Game):
print(game) # Print the TPS.
for row in game.board():
for square in row:
print(square)
This might seem right, but if you try to use it, you would see something like this (board position after 1. a6 f1
):
2,x5/x6/x6/x6/x6/x5,1 1 2
None
None
None
None
None
(Piece.Flat, [Color.White])
None
None
None
None
None
None
None
None
None
None
None
None
None
None
None
None
None
None
None
None
None
None
None
None
(Piece.Flat, [Color.Black])
None
None
None
None
None
We need to print all the squares in row in one line, but print
will automatically put a newline character \n
after printing.
Luckily, we can specify that we want the ending to be a space instead by adding end=" "
. We also want to split each row, so we should
add an empty print after the inner for-loop
.
def pretty_print(game: Game):
print(game) # Print the TPS.
for row in game.board():
for square in row:
print(square, end=" ")
print() # Print a newline after each row.
This already looks much better:
2,x5/x6/x6/x6/x6/x5,1 1 2
None None None None None (Piece.Flat, [Color.White])
None None None None None None
None None None None None None
None None None None None None
None None None None None None
(Piece.Flat, [Color.Black]) None None None None None
The keen-eyed readers might have noticed something is off. This should be the board after 1. a6 f1
, which means that there should be a black
flat in the top-left and a white flat in the bottom-right. What happened?
The rows are printed in the wrong order! This is because we are printing top to bottom, but the first row is corresponds to the first rank, which should be at the bottom. Let's reverse the order of the rows.
def pretty_print(game: Game):
print(game) # Print the TPS.
for row in reversed(game.board()):
for square in row:
print(square, end=" ")
print() # Print a newline after each row.
2,x5/x6/x6/x6/x6/x5,1 1 2
(Piece.Flat, [Color.Black]) None None None None None
None None None None None None
None None None None None None
None None None None None None
None None None None None None
None None None None None (Piece.Flat, [Color.White])
Great! This is technically already playable, but let's make it actually pretty by using some symbols. We will also limit to just one symbol per square so that the board stays aligned when more of the squares are filled out.
Let's use 🔳 for empty squares, (🟧 and 🟦) for flats, (🔶 and 🔷) for walls, and (🟠and 🔵) for capstones, for (white and black) respectively.
def pretty_print(game: Game):
print(game) # Print the TPS.
for row in reversed(game.board()):
for square in row:
# If the square is empty, print the empty symbol.
if square is None:
print("🔳", end=" ")
continue
# Print a symbol for the top piece of each stack.
piece, colors = square
if colors[-1] == Color.White:
if piece == Piece.Flat:
print("🟧", end=" ")
elif piece == Piece.Wall:
print("🔶", end=" ")
else:
print("🟠", end=" ")
else:
if piece == Piece.Flat:
print("🟦", end=" ")
elif piece == Piece.Wall:
print("🔷", end=" ")
else:
print("🔵", end=" ")
print() # Print a newline after each row.
2,x5/x6/x6/x6/x6/x5,1 1 2
🟦 🔳 🔳 🔳 🔳 🔳
🔳 🔳 🔳 🔳 🔳 🔳
🔳 🔳 🔳 🔳 🔳 🔳
🔳 🔳 🔳 🔳 🔳 🔳
🔳 🔳 🔳 🔳 🔳 🔳
🔳 🔳 🔳 🔳 🔳 🟧
2,x5/x3,1S,2,x/x2,12,2,x2/x2,1C,1,1,2/x,2,1,x3/1,x,1,2,x2 1 9
🟦 🔳 🔳 🔳 🔳 🔳
🔳 🔳 🔳 🔶 🟦 🔳
🔳 🔳 🟦 🟦 🔳 🔳
🔳 🔳 🟠🟧 🟧 🟦
🔳 🟦 🟧 🔳 🔳 🔳
🟧 🔳 🟧 🟦 🔳 🔳
That looks much better!
I do have one more issue though. I would like to find out the name of a square without having to count.
I want to have the ranks and files displayed on the sides of the board. For the ranks we can enumerate
the rows
starting at 1
before we reverse them. enumerate
is not reversible though, so we will also have to convert to a list
.
For the files, we can just print letters after we are done printing the board.
def pretty_print(game: Game):
print(game) # Print the TPS.
for rank, row in reversed(list(enumerate(game.board(), 1))):
print(rank, end=" ")
... # Same as before.
# Print the files.
print(" a b c d e f g h"[: 1 + game.size * 3])
2,x5/x6/x6/x6/x6/x5,1 1 2
6 🟦 🔳 🔳 🔳 🔳 🔳
5 🔳 🔳 🔳 🔳 🔳 🔳
4 🔳 🔳 🔳 🔳 🔳 🔳
3 🔳 🔳 🔳 🔳 🔳 🔳
2 🔳 🔳 🔳 🔳 🔳 🔳
1 🔳 🔳 🔳 🔳 🔳 🟧
a b c d e f
The letters for the files might appear misaligned on some browsers or in some terminals. Play around with the spacing if it doesn't look right for you. If you use different unicode symbols for your board you might also need different spacing.
I think that is plenty good for now. I encourage you to try your own version of pretty printing: You can change the symbols, print sizes for each stack, or maybe even display the stacks somehow. Have fun with it!
Before we leave pretty_print
for good, I want to make one stylistic change to the code: I am a big fan of match
statements and pattern matching;
it's probably my favorite language feature. I want to use it here instead of the nested if statements in the inner loop.
I didn't use it initially since Python only introduced match
statements in version 3.10, and I do not expect everyone to have that version.
def pretty_print(game: Game):
# Print the TPS.
print(game)
# Print the board.
for rank, row in reversed(list(enumerate(game.board(), 1))):
print(rank, end=" ")
for square in row:
# If the square is empty, print the empty symbol.
if square is None:
print("🔳", end=" ")
continue
# Print a symbol for the top piece of each stack.
piece, colors = square
match colors[-1], piece:
case Color.White, Piece.Flat:
print("🟧", end=" ")
case Color.White, Piece.Wall:
print("🔶", end=" ")
case Color.White, Piece.Cap:
print("🟠", end=" ")
case Color.Black, Piece.Flat:
print("🟦", end=" ")
case Color.Black, Piece.Wall:
print("🔷", end=" ")
case Color.Black, Piece.Cap:
print("🔵", end=" ")
# Print a newline after each row.
print()
# Print the files.
print(" a b c d e f g h"[: 1 + game.size * 3])
Final Touches
Now that we can actually see what is going we can play a game all the way to the end without getting lost. If you do that, you may notice that once you finish a game, the final position is not printed and neither is the winner. Let's fix that.
from takpy import new_game, GameResult, Move, Piece, Color, Game
def pretty_print(game: Game):
...
game = new_game(6)
while game.result == GameResult.Ongoing:
...
# Summary after the game.
pretty_print(game)
match game.result:
case GameResult.WhiteWin:
print("🟧 wins!")
case GameResult.BlackWin:
print("🟦 wins!")
case GameResult.Draw:
print("It's a draw!")
Alright. We have a working CLI to play Tak locally! We could end here, but let's do a tiny bit
of refactoring in preparation for the next few chapters. Let's move the loop and the final summary message
into a function that we call from an if __name__ == "__main__":
block.
We should do this so that when we import functions from this module (such as pretty_print
),
we want to avoid running the CLI. __name__
is only equal to "__main__"
when the current module
is the one that was launched by Python.
from takpy import new_game, GameResult, Move, Piece, Color, Game
def pretty_print(game: Game):
...
def cli():
game = new_game(6)
while game.result == GameResult.Ongoing:
...
# Summary after the game.
pretty_print(game)
match game.result:
...
if __name__ == "__main__":
cli()
And that's it! You can try adding some more features on your own. Here are some suggestions:
- Specify the board size from the command line. (Make them parameters of
cli
and read the arguments fromsys.argv
) - Allow undoing moves. (Keep track of the previous position (or even the whole history),
and if a player writes
undo
, replace the current game with the previous one) - Suggest a possible move if the user made a typo. (Compute [edit distance] for each possible move.)
You can find the final code for this chapter here: https://github.com/ViliamVadocz/takbot-tutorial/blob/main/part_1/bot.py
Move Ordering
We are finally ready to start making our bot! We'll start with something that is perhaps a bit unconventional: move ordering.
The usual way to introduce bot-making for board games (if one can even say there is a usual way) is to start with some kind of tree-search such as Minimax and then work through to Negamax, Alpha-Beta pruning, and eventually add Move Ordering to improve performance. In this tutorial we start with Move Ordering because I think it's a bit easier to grasp for someone who has never heard of graphs and [trees] in the context of games. As a bonus we'll get an IntuitionBot-style engine out of it which is easy to change.
The basic idea of Move Ordering is to rank moves from best to worst according to some heuristic. In the short term, our bot will just take the top move. Then we take a peek at planning by adding 1 move look-ahead so that our bot doesn't blunder losses in 1. Sounds like a plan?
Random Bot
Let's modify our code from the previous chapter to alternate between a player and a bot move.
We'll create two new functions player_move
and bot_move
that both receive a game state and
return a move. Then, we modify the the cli
function to check whether it's the player's or bot's
turn to move. We'll keep the player color in a new variable called player_color
.
def player_move(game: Game) -> Move:
...
def bot_move(game: Game) -> Move:
...
def cli():
player_color = Color.White
game = new_game(6)
while game.result() == GameResult.Ongoing:
pretty_print(game)
if game.to_move == player_color:
move = player_move(game)
else:
move = bot_move(game)
print(f"the bot played {move}")
game.play(move)
# Summary after the game.
pretty_print(game)
match game.result:
case GameResult.WhiteWin:
print("🟧 wins!")
case GameResult.BlackWin:
print("🟦 wins!")
case GameResult.Draw:
print("It's a draw!")
Inside player_move
we want to keep asking the user for input until they enter a valid move
(both in terms of PTN and validity in the board state). We will do this by using a while True
loop and breaking only once we succeed in playing a move.
def player_move(game: Game) -> Move:
while True:
user_input = input("enter move: ")
try:
move = Move(user_input)
except ValueError as error:
print(f"invalid PTN: {error}")
continue
try:
game.clone_and_play(move) # try the move
return move # valid move was entered and played
except ValueError as error:
print(f"invalid move: {error}")
To test whether we have completed this transformation without introducing any bugs,
let's just make bot_move
play a random valid move.
We can get a list of valid moves using game.possible_moves()
, and then for picking a random
value out of a list we can use random.choice
(once we import it).
import random # Put this at the top of the file.
def bot_move(game: Game) -> Move:
"""Pick a move automatically."""
random_move = random.choice(game.possible_moves())
return random_move
If you followed along, you can now play against a random bot!
A Little Cleanup
A random bot is not very interesting, so let's add to it. We will be adding a lot, so in preparation
for that let's move everything except bot_move
to it's own file called cli.py
Then, to make things work again, we need to import bot_move
from bot.py
like this (put it at the top of cli.py
):
from bot import bot_move
Make sure to add missing imports to
bot.py
, and remove any unnecessary ones incli.py
(such asimport random
).
Try running python cli.py
now. You should be able to play against your random bot again.
Ranking Moves
In this tutorial we will implement move ordering by giving each move a score.
The moves with the highest score will be ranked first.
Let's implement this by adding a new function move_score
which gets a Move, and returns an number.
def move_score(move: Move) -> float:
"""Give each move a score. Larger is better."""
return 0
Then, in bot_move
, we can pick the move that scores highest according to this function.
We do this with max
, and by specifying our move_score
function as the key
.
This will run our function for each move, and return the one that gives the highest output.
def bot_move(game: Game) -> Move:
"""Pick a move automatically."""
possible_moves = game.possible_moves()
best_move = max(possible_moves, key=move_score)
return best_move
Often the best move depends on the board state, so it would be useful to have access to that
in the scoring function (currently we only have access to the move). We might also compute
some statistics later, and we would want to reuse them for each move. The easiest way to do
that right now is to just define move_score
inside bot_move
, so that it has access to
all our local variables such as the game
.
def bot_move(game: Game) -> Move:
"""Pick a move automatically."""
def move_score(move: Move) -> float:
"""Give each move a score. Larger is better."""
return 0
possible_moves = game.possible_moves()
best_move = max(possible_moves, key=move_score)
return best_move
Heuristics
And now comes the fun part. What makes one move better than another? Why is some move good in one situation, but then the same move is bad in another? If you know the answers to those questions, you've just solved Tak! I don't, so instead I will have to come up with some guesses based on my experience.
Placements over Spreads
To make any sort of threat, one needs to make placements. Maybe we can start by valuing placements above spreads.
# You need to import `MoveKind` as well.
from takpy import GameResult, Move, Piece, Color, Game, MoveKind
def bot_move(game: Game) -> Move:
"""Pick a move automatically."""
def move_score(move: Move) -> float:
"""Give each move a score. Larger is better."""
match move.kind:
case MoveKind.Place:
return 100
case MoveKind.Spread:
return 0
... # Same as before.
Now, all placements will get a score of 100
, while all spreads get a score of 0
.
This essentially means the bot will never spread, because any placement will have higher score.
The placement location and piece type will depend on what order the moves are generated in.
It's better than nothing, but we can do much better.
We'll need to combine multiple different heuristics, so let's keep a score
variable which we will add to.
Let's also name our constants so that we don't end up with a bunch of magic numbers.
PLACEMENT = 100
SPREAD = 0
def bot_move(game: Game) -> Move:
"""Pick a move automatically."""
def move_score(move: Move) -> float:
"""Give each move a score. Larger is better."""
score = 0
match move.kind:
case MoveKind.Place:
score += PLACEMENT
case MoveKind.Spread:
score += SPREAD
return score
...
Flats > Capstones > Walls
Let's prioritize placing flats over capstones, and capstones over walls:
PLACEMENT = 100
SPREAD = 0
FLAT = 100
CAP = 50
WALL = 0
def bot_move(game: Game) -> Move:
"""Pick a move automatically."""
def move_score(move: Move) -> float:
"""Give each move a score. Larger is better."""
score = 0
match move.kind:
case MoveKind.Place:
score += PLACEMENT
case MoveKind.Spread:
score += SPREAD
match move.piece:
case Piece.Flat:
score += FLAT
case Piece.Cap:
score += CAP
case Piece.Wall:
score += WALL
return score
...
Note that
move.piece
will returnNone
if the move was a spread.
Making Roads
We should also think about where it's good to place. To win, the bot should make road threats, and thus we might want to prioritize squares which continue our existing roads. Calculating which square benefits most roads is hard, but a decent heuristic is prioritizing squares that are in the same row or column as existing road pieces.
A road piece is either a flat or capstone. We will check for this often,
so let's make it a function road_piece
.
def road_piece(piece: Piece | None) -> bool:
return piece == Piece.Flat or piece == Piece.Cap
The input type is
Piece | None
because we will generally use it in combination withmove.piece
which could beNone
if the move is a spread.
For the heuristic mentioned above, let's try counting how many road pieces are in the same row or column as some given square. For example, for the position below, I have looked at each empty square, and counted how many road pieces are in the same row or column (as white).
For this example, f5
and f1
would score highly, which is what we want since those placements build towards a road.
The row and column information doesn't change between the different moves that
we are scoring, so we can just calculate it once in the outer function bot_move
.
To keep our code modular, let's split the implementation into multiple functions
that we can reuse for other heuristics.
from collections.abc import Iterable
# Helper types
Stack = tuple[Piece, list[Color]]
Board = list[list[None | Stack]]
def count_road_pieces(stacks: Iterable[None | Stack], color: Color) -> int:
"""Count how many stacks have a road piece with our color on top."""
only_stacks = (stack for stack in stacks if stack is not None)
return sum(
road_piece(piece) for piece, colors in only_stacks if colors[-1] == color
)
def columns(board: Board) -> Board:
"""Get the columns of a board."""
return [[row[i] for row in board] for i in range(len(board[0]))]
def row_score(board: Board, color: Color) -> list[int]:
"""Count the road pieces per row."""
return [count_road_pieces(row, color) for row in board]
def col_score(board: Board, color: Color) -> list[int]:
"""Count the road pieces per column."""
return [count_road_pieces(col, color) for col in columns(board)]
This might be a little overwhelming at first, especially since I used list comprehensions and generator expressions which you might not be familiar with. A good exercise is to try implementing it yourself in your own way, or to try modifying it to see what happens.
Now we can precalculate the row and column scores, and look them up when ranking placements.
...
ROW_COLUMN_ROAD = 10
def bot_move(game: Game):
"""Pick a move automatically."""
board = game.board()
# Precompute row and column scores.
my_row_score = row_score(board, game.to_move)
my_col_score = col_score(board, game.to_move)
def move_score(move: Move) -> float:
"""Give each move a score. Larger is better."""
score = 0
row, column = move.square
match move.kind: ...
match move.piece: ...
if road_piece(move.piece):
score += ROW_COLUMN_ROAD * (my_row_score[row] + my_col_score[column])
return score
...
With that implemented, the bot now tries to build a road, although it doesn't take into account when it has been blocked. Maybe we can encourage placing the capstone next to opponent stacks so that the bot has the opportunity to capture onto them for a road in the future.
Capstone Next to Stacks
For this, we will need to find out the neighboring stacks to any given square. We have to be careful about not accessing positions which do not exist on the board, but once that is taken care of, it's not difficult. We can make it a function for convenience.
def neighbor_stacks(board: Board, size: int, row: int, col: int) -> list[Stack]:
"""Get the neighboring stacks to a square."""
neighbors = []
if row < size - 1:
neighbors.append(board[row + 1][col])
if row >= 1:
neighbors.append(board[row - 1][col])
if col < size - 1:
neighbors.append(board[row][col + 1])
if col >= 1:
neighbors.append(board[row][col - 1])
return [n for n in neighbors if n is not None]
Then we can look at the neighboring stacks when placing a capstone and add a bonus for each opponent stack, multiplied by height to encourage attacking tall stacks.
...
CAP_NEXT_TO_OPPONENT_STACK = 50
def bot_move(game: Game) -> Move:
"""Pick a move automatically."""
board = game.board()
my_color = game.to_move
opp_color = game.to_move.next()
# Precompute row and column scores.
my_row_score = row_score(board, my_color)
my_col_score = col_score(board, my_color)
def move_score(move: Move) -> float:
"""Give each move a score. Larger is better."""
score = 0
row, column = move.square
neighbors = neighbor_stacks(board, game.size, row, column)
match move.kind: ...
match move.piece:
case Piece.Flat:
score += FLAT
case Piece.Cap:
score += CAP
for _piece, colors in neighbors:
if colors[-1] == opp_color:
score += CAP_NEXT_TO_OPPONENT_STACK * len(colors)
case Piece.Wall:
score += WALL
if road_piece(move.piece): ...
return score
...
Central Placements
Let's also add a bonus for placing near the center to help the bot pivot once it cannot make a threat in once direction.
For this we can calculate the distance from the center. We will use Manhattan distance because Tak is played on grid and road connections are orthogonal. It's more natural to work with.
def distance_from_center(row: int, col: int, size: int) -> float:
mid = (size - 1) / 2
return abs(row - mid) + abs(col - mid)
Then we use this function to get the distance, and subtract it from the score when placing.
...
CENTER_PLACEMENT = 10
def bot_move(game: Game) -> Move:
...
def move_score(move: Move) -> float:
"""Give each move a score. Larger is better."""
score = 0
row, column = move.square
neighbors = neighbor_stacks(board, game.size, row, column)
distance = distance_from_center(row, column, game.size)
match move.kind:
case MoveKind.Place:
score += PLACEMENT
# We subtract the distance from the center
score -= CENTER_PLACEMENT * distance
case MoveKind.Spread:
score += SPREAD
match move.piece: ...
if road_piece(move.piece):
score += ROW_COLUMN_ROAD * (my_row_score[row] + my_col_score[column])
return score
...
Opening Swap
If you try playing the bot now, you'll see something strange. It places your first piece in the center.
The bot does not consider that for the first two plies, players play the opponents piece.
There are several ways to address this (you could even hardcode an opening), but maybe easiest
is just to pick the lowest scoring move for the first two plies. This corresponds to using min
when game.ply < 2
def bot_move(game: Game):
...
if game.ply < 2:
best_move = min(possible_moves, key=move_score)
else:
best_move = max(possible_moves, key=move_score)
One-move Lookahead
The bot plays... well, still very badly. It's better than random, but it has a lot of room for improvement. Let's move on from heuristics though, because we can also improve the bot in other ways.
I think the best thing we can do for our bot right now would be adding search. I want to leave that for the next chapters, so instead I suggest we limit ourselves to a single move lookahead. That means taking winning moves when we have them, and to prevent roads in 1 (when possible).
The idea is to use the score to order the moves from best to worst, and to try them on a "virtual" board to see if it wins. If yes, we can just play it. We can likewise check the opponent's moves before playing our prospective move to see whether they can win. If yes, we should avoid playing that move. move.
Whether it's for us or for the opponent, we want to know if the current player can win. Let's implement a function to check that and give us the winning move.
def winning_move(game: Game, moves: list[Move]) -> Move | None:
for move in moves:
after_move = game.clone_and_play(move)
if after_move.result.color() == game.to_move:
return move
return None
Notice that the order of the moves we pass in matters. If the move ordering gives us likely winning moves early, then we do not have to simulate as many moves. This idea of searching promising moves first will come up again when implementing Alpha-Beta search in the next few chapters.
Since we care about more than just the best move now, we will sort the moves instead of just taking the maximum or minimum scoring one.
Now we can use that function in bot_move
to take immediate wins.
def bot_move(game: Game):
...
def move_score(move: Move):
...
possible_moves = game.possible_moves()
sorted_moves = sorted(possible_moves, key=move_score, reverse=game.ply >= 2)
best_move = sorted_moves[0]
possibly_winning = winning_move(game, sorted_moves)
if possibly_winning is not None:
# Take immediate wins.
best_move = possibly_winning
return best_move
sorted
is similar tomin
andmax
in that it takes akey
function. It would normally order the moves from smallest score to highest, so we reverse the order when the is out of the opening swap.
Before we add avoidance for losses in 1, let's so some minor refactoring because this bot_move
function has grown quite large. Let's make a function move_ordering
which takes a list
of moves and returns it in order. We will transplant most of bot_move
into it.
def move_ordering(game: Game) -> list[Move]:
"""Return an ordering of the possible moves from best to worst."""
board = game.board()
my_color = game.to_move
opp_color = game.to_move.next()
# Precompute row and column scores.
my_row_score = row_score(board, game.to_move)
my_col_score = col_score(board, game.to_move)
def move_score(move: Move) -> float:
...
possible_moves = game.possible_moves()
return sorted(possible_moves, key=move_score, reverse=game.ply >= 2)
def bot_move(game: Game):
"""Pick a move automatically."""
sorted_moves = move_ordering(game)
best_move = sorted_moves[0]
possibly_winning = winning_move(game, sorted_moves)
if possibly_winning is not None:
# Take immediate wins.
best_move = possibly_winning
return best_move
Then we can decompose move_score
into more helper functions to make it a bit easier on the eyes:
def move_kind_bonus(kind: MoveKind, distance: float) -> float:
"""Get the move score bonus based on the move kind."""
match kind:
case MoveKind.Place:
return PLACEMENT - CENTER_PLACEMENT * distance
case MoveKind.Spread:
return SPREAD
def piece_type_bonus(
piece: Piece | None, opp_color: Color, neighbors: list[Stack]
) -> float:
"""Get the move score bonus based on the piece type."""
match piece:
case Piece.Flat:
return FLAT
case Piece.Cap:
score = CAP
for _piece, colors in neighbors:
if colors[-1] == opp_color:
score += CAP_NEXT_TO_OPPONENT_STACK * len(colors)
return score
case Piece.Wall:
return WALL
case None:
return 0
def move_ordering(game: Game) -> list[Move]:
"""Return an ordering of the possible moves from best to worst."""
board = game.board()
my_color, opp_color = game.to_move, game.to_move.next()
# Precompute row and column scores.
my_row_score = row_score(board, game.to_move)
my_col_score = col_score(board, game.to_move)
def move_score(move: Move) -> float:
"""Give each move a score. Larger is better."""
score = 0
row, column = move.square
distance = distance_from_center(row, column, game.size)
neighbors = neighbor_stacks(board, game.size, row, column)
score += move_kind_bonus(move.kind, distance)
score += piece_type_bonus(move.piece, opp_color, neighbors)
if road_piece(move.piece):
score += ROW_COLUMN_ROAD * (my_row_score[row] + my_col_score[column])
return score
possible_moves = game.possible_moves()
return sorted(possible_moves, key=move_score, reverse=game.ply >= 2)
Alright, that should do.
Avoiding a road in 1 is a bit more complicated than picking a winning move, but not by much. The basic idea is that we try each move, and then if the game is not lost already, we check if the opponent has a winning move. If they don't we can play this move and we break out of the loop.
def bot_move(game: Game):
sorted_moves = move_ordering(game)
best_move = sorted_moves[0]
possibly_winning = winning_move(game, sorted_moves)
if possibly_winning is not None:
# Take immediate wins.
best_move = possibly_winning
else:
# Look for the first non-losing move.
for my_move in sorted_moves:
after_my_move = game.clone_and_play(my_move)
if after_my_move.result().color() == after_my_move.to_move:
continue # I made a road for the opponent accidentally.
if winning_move(after_my_move, move_ordering(after_my_move)) is None:
best_move = my_move
break
return best_move
Try playing the bot now. It plays much better!
It can still fall into Tinue easily, but I'll leave that as an exercise for the reader.
Some things you can try implementing:
- Blocking enemy roads (maybe using
row_score
andcol_score
) - Discourage flat-on-flat captures (Check whether the top piece of the spreading stack is a flat)
- Encourage placing walls near stacks with captives
- Avoid making negative flat-count-difference (FCD) moves (Compute the FCD of spreads)
- Reward strong shapes (citadels, staircases)
Some ideas to think about before the next chapter:
- When an opponent has a Tak threat, we need to prevent it. That means that for every move we want to play, we have to check that same move, and if is doesn't stop it, we can immediately reject our candidate move. If we are able to identify these "killer" moves, checking for them first would improve performance. Can you think of how we could implement that?
- The bot is currently searching 2 plies ahead. That means we consider our current move, and our opponents response.
How could you look 3 plies ahead? 4 plies? arbitrary
N
plies? - Do we really have to look at all moves? Are there some moves which are so bad that we should not even consider them? Could you figure out a way to identify such moves?
- You have some ideas about how to recognize good moves. What about good board positions? What makes one Tak position better than another? Are there any ideas you can re-use from move ordering?
You can find the final code for this chapter here: https://github.com/ViliamVadocz/takbot-tutorial/tree/main/part_2
Talking to PlayTak
We have a semi-decent bot on our hands. Let's try to get it online!
We will put our bot on PlayTak which will require adhering to its bespoke protocol, described it the GitHub repository for the PlayTak server. The protocol is somewhat annoying to work with, so we will ignore most of it except for the bare essentials required to play a game.
The plan is this:
- Establish connection to PlayTak.
- Keep connection alive with periodic
PING
s in the background. - Login and create a seek.
- Once in a game, send and receive moves from our bot.
Connecting to PlayTak
We will talk to PlayTak using the WebSocket protocol with the help of the third-party library [websockets
].
Install it with pip
:
pip install websockets
We will handle all of PlayTak communication in a new file called playtak.py
.
We start our implementation by opening a connection, receiving two messages to check that things are working,
and then quitting. Unfortunately, PlayTak is not a nicely behaved WebSocket server, so we have to explicitly
state the subprotocol and disable ping timeout. All of that looks like this:
import asyncio
from websockets.asyncio.client import connect, ClientConnection
from websockets import Subprotocol
async def talk_to_playtak():
URI = "ws://playtak.com:9999/ws"
subprotocols = [Subprotocol("binary")]
async with connect(URI, subprotocols=subprotocols, ping_timeout=None) as ws:
print(await ws.recv()) # Welcome!
print(await ws.recv()) # Login or Register
await ws.send("quit")
if __name__ == "__main__":
asyncio.run(talk_to_playtak())
Thanks to @the1Rogue on Discord for helping me out here.
We will be using [asyncio
] for asynchronous execution so that we talk to the PlayTak server while our bot is thinking.
Don't worry if you have never used async-await
syntax. It will be isolated to code which deals with PlayTak communication,
and all of that you can just copy from the tutorial.
The code above opens a WebSocket connection to PlayTak with connect()
and we name the connection ws
.
To receive a message over the connection we use ws.recv()
and to send messages we use ws.send()
.
We use await
if we want to wait for the result (i.e. waiting until we get a message, or until our message is fully sent).
Keeping the connection alive
WebSocket supports sending pings (short round-trip heartbeat messages) natively, but unfortunately the PlayTak server doesn't deal with
them correctly. Instead, the PlayTak protocol requires us to send the text PING
every half-minute, to which it replies with OK
.
For simplicity we won't be trying to match up our requests to specific OK
responses. We also won't be checking whether we received this OK
.
This makes pinging periodically relatively easy:
async def ping(ws: ClientConnection, stop_event: asyncio.Event):
PERIOD_SECONDS = 30
while not stop_event.is_set():
await asyncio.sleep(PERIOD_SECONDS)
await ws.send("PING")
async def talk_to_playtak():
URI = "ws://playtak.com:9999/ws"
subprotocols = [Subprotocol("binary")]
async with connect(URI, subprotocols=subprotocols, ping_timeout=None) as ws:
# Start background task.
stop_event = asyncio.Event()
ping_task = asyncio.create_task(ping(ws, stop_event))
await asyncio.sleep(5) # Pretend we are doing something.
# Stop background task.
stop_event.set()
await ping_task
await ws.send("quit")
if __name__ == "__main__":
asyncio.run(talk_to_playtak())
We start a background task ping
which will periodically send PING
across the WebSocket connection.
It will stop only once we set the stop_event
. We can reuse this pattern for any future long-running
background tasks.
Making seeks
The server won't cut our connection off; good. Let's login as a guest and make a seek.
def seek(size: int, clock_seconds: int, increment_seconds: int) -> str:
return f"Seek {size} {clock_seconds} {increment_seconds}"
async def talk_to_playtak():
URI = "ws://playtak.com:9999/ws"
subprotocols = [Subprotocol("binary")]
async with connect(URI, subprotocols=subprotocols, ping_timeout=None) as ws:
stop_event = asyncio.Event()
ping_task = asyncio.create_task(ping(ws, stop_event))
await ws.send("Login Guest")
await ws.send(seek(6, 5 * 60, 5)) # Seek 6x6, 5 min + 5 sec
await asyncio.sleep(5) # Pretend we are doing something.
stop_event.set()
await ping_task
await ws.send("quit")
Once someone accept the seek and the game starts, we will receive a Game Start
message that looks like this:
Game Start 645331 6 Guest535 vs x57696c6c white 300 0 30 1 0 0
Where the pattern is this:
Game Start <GAME_ID> <SIZE> <WHITE_PLAYER> vs <BLACK_PLAYER> <MY_COLOR> <TIME_SECONDS> <HALF_KOMI> <FLATS> <CAPSTONES> <UNRATED> <TOURNAMENT>
Since we are the one who started the seek, all we care about is the game ID and our color. We can wait until we get that message.
from takpy import Color
def to_color(s: str) -> Color:
match s:
case "white":
return Color.White
case "black":
return Color.Black
case _:
raise ValueError("invalid color")
async def wait_until_game_start(ws: ClientConnection) -> tuple[int, Color]:
while True:
msg = await ws.recv()
assert isinstance(msg, bytes) # subprotocol is "binary"
match msg.decode().split():
# Game Start 645331 6 Guest535 vs x57696c6c white 300 0 30 1 0 0
case ["Game", "Start", game_id, _, _, "vs", _, color, *_]:
return int(game_id), to_color(color)
async def talk_to_playtak():
...
async with connect(URI, subprotocols=subprotocols, ping_timeout=None) as ws:
...
await ws.send("Login Guest")
await ws.send(seek(6, 5 * 60, 5))
game_id, my_color = await wait_until_game_start(ws)
print(game_id, my_color)
...
You can try running this code, joining your seek from the browser, and see that it abandons the game after a while
(It will abandon after the ping
coroutine (fancy word for async
function) finishes,
which might take some time because it could be in the middle of sleeping).
Playing the game
Let's make the bot play the game! Unfortunately, PlayTak does not use PTN to send its moves.
Let's build two functions, one for converting from our Move
to PlayTak notation,
and another to parse PlayTak notation into Move
.
PlayTak notation Summary:
A square is always a capital letter for the column followed by a 1-indexed row number. No space.
Examples of placements:
P A1 # a1 place a flat on a1 P B2 C # Cb2 place a capstone on b2 P C3 W # Sc3 place a wall on c3
The pattern is
P <SQUARE> <PIECE>
Examples of spreads:
M A6 A4 2 1 # 3a6-21 M B3 E3 1 2 1 # 4b3>121
The pattern is
M <START> <END> <DROPS>
.
def to_playtak_square(row: int, col: int) -> str:
return "ABCDEFGH"[col] + str(row + 1)
def to_playtak_notation(move: Move) -> str:
row, col = move.square
start = to_playtak_square(row, col)
match move.kind:
case MoveKind.Place:
piece = ""
match move.piece:
case Piece.Flat:
pass
case Piece.Wall:
piece = " W"
case Piece.Cap:
piece = " C"
return f"P {start}{piece}"
case MoveKind.Spread:
drop_counts = move.drop_counts()
assert drop_counts is not None
end_row, end_col = row, col
match move.direction:
case Direction.Up:
end_row += len(drop_counts)
case Direction.Down:
end_row -= len(drop_counts)
case Direction.Left:
end_col -= len(drop_counts)
case Direction.Right:
end_col += len(drop_counts)
direction = move.direction
end = to_playtak_square(end_row, end_col)
drops = " ".join(str(x) for x in drop_counts)
return f"M {start} {end} {drops}"
To convert to a Move
from PlayTak notation I build an intermediate PTN,
since takpy
only exposes that way to make Move
(maybe I should change that).
With that, converting from PlayTak notation looks like this:
def from_playtak_notation(s: str) -> Move:
match s.split():
case ["P", square, *maybe_piece]:
piece = ""
match maybe_piece:
case ["W"]:
piece = "S"
case ["C"]:
piece = "C"
return Move(piece + square.lower()) # type: ignore
case ["M", start, end, *drops]:
direction = ""
match ord(end[0]) - ord(start[0]), ord(end[1]) - ord(start[1]):
case x, y if x > 0:
direction = ">"
case x, y if x < 0:
direction = "<"
case x, y if y > 0:
direction = "+"
case x, y if y < 0:
direction = "-"
return Move(f"{sum(int(x) for x in drops)}{start.lower()}{direction}{"".join(drops)}") # type: ignore
case _:
raise ValueError(f"unrecognized move: {s}")
Feel free to implement the conversion your own way if you like.
The last step to actually receiving moves is filtering through the received messages
until it contains a move for our game, which looks like Game#<GAME_ID> <PLAYTAK_NOTATION>
:
async def wait_until_move(ws: ClientConnection, game_id: int) -> Move:
while True:
msg = await ws.recv()
assert isinstance(msg, bytes) # subprotocol is "binary"
match msg.decode().strip().split(maxsplit=1):
case [game, notation]:
if game == f"Game#{game_id}" and notation.startswith(("P", "M")):
return from_playtak_notation(notation)
We have to check that the notation starts with
P
orM
because messages which inform us about time look identical.
Armed with these functions, we can make our bot play a game!
async def talk_to_playtak():
URI = "ws://playtak.com:9999/ws"
subprotocols = [Subprotocol("binary")]
async with connect(URI, subprotocols=subprotocols, ping_timeout=None) as ws:
stop_event = asyncio.Event()
ping_task = asyncio.create_task(ping(ws, stop_event))
await ws.send("Login Guest")
await ws.send(seek(6, 5 * 60, 5))
print("Waiting for someone to join the seek...")
game_id, my_color = await wait_until_game_start(ws)
print(f"Game started! id: {game_id}, my_color: {my_color}")
game = new_game(6)
while game.result() == GameResult.Ongoing:
if game.to_move == my_color:
move = bot_move(game)
await ws.send(f"Game#{game_id} {to_playtak_notation(move)}")
print("bot played: {move}")
else:
move = await wait_until_move(ws, game_id)
print("opp played: {move}")
game.play(move)
print(f"result: {game.result()}")
stop_event.set()
print("Waiting for all background tasks to finish...")
await ping_task
await ws.send("quit")
You can now play against your bot on PlayTak! Have fun!
There are many things you could add from here on out. Here are some of my suggestions:
- Make your bot seek with komi (try to see if you can figure out how to do that by reading the GitHub repository for the PlayTak server).
- Trash talk your opponent! (You can get their name from the start game message, and then use
Tell <PLAYER> <MESSAGE>
to directly message them). - Make a new seek after the game ends, but also handle keyboards interrupts nicely (allow for graceful shutdown of background tasks).
- Give your bot a name by making a profile for them, and then message
@bcreature
on Discord to add them to the bot list. - Make the seek configurable through command line arguments, or perhaps even through chat!
(You can look for
Shout <PLAYER> <MESSAGE>
messages to read the Global chat.) - Retrieve how much time your bot has left (this may be useful in the next chapter).
The code for this chapter can be found here: https://github.com/ViliamVadocz/takbot-tutorial/tree/main/part_3