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:

  1. Explore how we Represent Tak inside our code.
  2. Build a command-line interface to Play Tak Locally.
  3. Implement a simple heuristic agent using Move Ordering.
  4. Add your bot to PlayTak in Talking to PlayTak.
  5. Make the engine smarter by thinking with Tree Search.

Prerequisites

You will need to know:

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 the takpy 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 and pip3 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 by 5 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.

Position with stacks

>>> 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 from 0.

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 from sys.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 in cli.py (such as import 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 return None 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 with move.piece which could be None 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).

An opening position where each empty square has a number counting how many road pieces are in the same row or column as that square

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 to min and max in that it takes a key 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 and col_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:

  1. Establish connection to PlayTak.
  2. Keep connection alive with periodic PINGs in the background.
  3. Login and create a seek.
  4. 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 or M 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