Hooking up the Sunfish Chess Engine (Advanced)


In the earlier posts on MVC [1, 2, 3] we did a fair bit of work putting together a GUI for a chess game.  It was  all very interesting but for the fact that there was no chess engine behind it … so you couldn’t actually play a game of chess with it (d’oh!).  This tutorial is aimed at remedying that by hooking up the Sunfish chess engine.  The Sunfish engine is written entirely in Python – awesome.  The main objective we are trying to achieve is to reuse existing code, including, in the case of Sunfish, someone else’s code.  This will primarily be achieved through subclassing existing classes.   This is an advanced topic, so you might want some help from a responsible adult.

The Rules

The rules of this  tutorial are that we must not make any changes to the Sunfish code, we must use the interface code from this earlier tutorial but we are allowed to subclass that code as necessary to interface properly with Sunfish.

TL;DR

Get yourself a copy of:

  • sunfish.py from the Sunfish github (it offers a zip download, you only need sunfish.py from that archive)
  • the code from the earlier tutorial (save it to mvc2.py)
  • a copy of the chess_data directory from the earlier tutorial as well (this contains the image data for each of the chess pieces that will be needed by the GUI).  If you run mvc2.py once should check and auto-download the data.

Then, save the code at the end of this tutorial into a file (I have called mine chess.py) in the same directory as sunfish.py and mvc2.py and you should be up and running!  The rest of the tutorial explains the process of developing the chess.py code below.

Discussion

We already have a model, controller and view in the mvc2 code.   What we need to do is integrate  Sunfish into these existing objects.  The final code has kept the view, but modified the Controller, and substituted an entirely new Model.   In order to interface our existing code with the Sunfish code we need to understand how the Sunfish code works.  First and foremost, Sunfish stores its board as a single string with line breaks (“\n”).   This is how Sunfish represents the board after the move e2-e4:

'          \n         \nrnbkqbnr \nppp.pppp \n........ \n...p.... \n........ \n........ \nPPPPPPPP \nRNBKQBNR \n         \n         '

In our earlier model we represented the board as a list of lists, with a separate element for each of the squares of the board, so this change means that we are going to have to process the Sunfish boards before we can pass them to our View. Other things to note are:

  • Sunfish pads the board with blank lines at the top and the bottom
  • Sunfish pads the board with a space at the end of each row (and further investigation shows this padding swaps from the left to right side)
  • Sunfish uses the “.” character to represent empty squares, while we used a blank space.

So the first thing to do is to write some code to convert a Sunfish board to a board that the View can display.  This was done in a convert method:

    def convert(self, board):
        """
        Sunfish adds blank lines, spaces and carriage returns to its board for its processing
        however the code we are using to display this assumes that board
        is an array of string.  So, convert
        Also need to convert "." representing an empty square to " " for the current view
        :param board:
        :return:
        """

        b = board.split('\n')
        converted_board = []
        for line in b[2:-2]:  # sunfish has two blank lines at top and bottom
            if line[0]== " ":
                line = line[1:].replace("."," ")
                # sunfish also has left padding sometimes
            else:
                line = line.replace("."," ")
            converted_board.append([c for c in line])  # split each line
        return converted_board

This splits the lines out, strips out the blank lines at the top and bottom. Then, it breaks each line into individual characters, stripping out a leading blank (if any) and converts the empty space characters from “.” to ” “.
I said that convert is a method – the logical place for it is in the controller in order to prepare data to give to the view (it could also go in the model). We also need to have something converting the board before passing the data to the View. The logical place for this is to intercept the call to the view in the update_display() method of the Controller, pass the data to the convert method, then pass that data to the View in the format it expects.

Finding a Place to Call Convert

However, one of our rules was that we couldn’t change the existing code. So what we have to do is subclass the Controller:

class NewController(mvc2.Controller):

This says we’re making a new class (called NewController) based on the Controller from the file mvc2.py (we would need to import mvc2 to do this). The new class has all of the code from the earlier Controller class. We can augment that code by adding the convert method above. However, nothing in Controller code will ever call the convert method (because when Controller was written, convert didn’t exist!). So we need to actually meddle in at least some of the existing methods of Controller. We do this by defining a function with the same name in NewController. This is called “overriding”. So, in order to hook up the new convert method I rewrote the the update_display method in Controller:

    def update_display(self,  debug_board=True, board=None):
        if board is None:
            self.v.display(self.convert(self.m.pos_list[-1].board),  debug_board = debug_board)
        else:
            self.v.display(self.convert(board))
        self.v.canvas.update_idletasks()
        # sometimes tkinter is stalling, so flush instructions

In essence I have made two changes – first, you can pass board as a named parameter and second, I have made all data going to the view’s display method (self.v.display) to first go through the self.convert method (the code below also has a “headless” option which is included in anticipation of later testing). Compare this to the code from Controller:

    def update_display(self,  debug_board= False):
        self.v.display(self.m.board,  debug_board = debug_board)

Preparing Data to Give To Sunfish

Not only is it necessary to convert Sunfish’s output in order to display the board, moves from the board have to be converted in order to send them to Sunfish. Sunfish has its own parse function which we can use, but only if the move is in algebraic notation (like e2e4). So, we need to create a function which converts the BoardLocation objects used in mvc2 into algebraic notation. These can then be fed into Sunfish’s parser:

ALGEBRAIC_DATA=zip([0,1,2,3,4,5,6,7],[c for c in "hgfedcba"])
ALGEBRAIC_DICT = {}
for k,v in ALGEBRAIC_DATA:
    ALGEBRAIC_DICT[k] = v

def location_to_algebraic(board_location):
    return "%s%s"%(ALGEBRAIC_DICT[7-board_location.j],8-board_location.i)

Move Logic

While we could make do with most of the move logic in mvc2 (in the handle_click method), it is a little basic.  For this exercise I have added the concept of “state”.  Which is to say the interface is in a certain state and that state affects how the interface reacts to clicks.  The three states are:

SELECT_PIECE, MOVE_PIECE, WAIT_COMPUTER = range(3)

So, the computer first waits for you to select a piece, then, when you have selected a valid piece, waits for you to move it to a valid location.   After that it waits for the computer to make its move.   This “stateful” approach means that you can unselect a piece during a move if you want to by clicking it again.  At the moment though, I haven’t added any visual cue to tell you which piece is currently selected.  The overridden method is “handle_click”.   So the bindings are still the same, but now when Tkinter generates an event the click event goes to the new handle_click method, not the old one.

def handle_click(self,  event):
    ''' Handle a click received.  The x,y location of the click on the canvas is at
    (event.x, event.y)
    First, we need to translate the event coordinates (ie the x,y of where the click occurred)
    into a position on the chess board
    add this to a list of clicked positions
    every first click is treated as a "from" and every second click as a"to"
    so, whenever there are an even number of clicks, use the most recent to two to perform a move
    then update the display
    '''

    logging.debug("in handle click valid moves are: \n%s"%self.m.legal_moves)
    j = event.x/mvc2.TILE_WIDTH
    #  the / operator is called integer division
    # it returns the number of times TILE_WIDTH goes into event.x ignoring any remainder
    # eg: 2/2 = 1, 3/2 = 1, 11/5 = 2 and so on
    # so, it should return a number between 0 (if x < TILE_WIDTH) though to 7
    i = event.y/mvc2.TILE_WIDTH

    b_loc = mvc2.BoardLocation(i, j)

    # Select/deselect logic

    sunfish_pos =  sunfish.parse(location_to_algebraic(b_loc))
    logging.debug("received a click at %s"%sunfish_pos)

    if self.click_state == WAIT_COMPUTER:
        logging.debug("wait computer: Currently waiting for computer ignoring clicks")
        return

    elif self.click_state == SELECT_PIECE:
        logging.debug("valid clicks are: %s"%self.m.valid_clicks)
        if sunfish_pos in self.m.valid_clicks:
            self.clickList.append(sunfish_pos)
            logging.debug("select piece: piece selected at: %s"%(sunfish_pos))
            self.click_state = MOVE_PIECE
            self.m.valid_clicks = [m[1] for m in self.m.legal_moves if m[0] == sunfish_pos]
            logging.debug("valid clicks now: %s"%self.m.valid_clicks)
            return
        else:
            return

    else: #State is MOVE_PIECE
        logging.debug("move piece: valid clicks are: %s"%self.m.valid_clicks)
        if sunfish_pos == self.clickList[-1]:
            logging.debug("piece moved to: %s"%(sunfish_pos))
            # that is, the currently selected piece
            # unselect that piece
            self.clickList.pop()
            self.click_state = SELECT_PIECE
            self.m.valid_clicks = set([m[0] for m in self.m.legal_moves])
            return

        elif sunfish_pos not in self.m.valid_clicks:
            # wait for a valid move
            return
        else:
            self.clickList.append(sunfish_pos)
            self.click_state = WAIT_COMPUTER
            # execute the move:
            pos = self.m.move(self.clickList[-2], self.clickList[-1])
            logging.debug(repr(pos.board))
            logging.debug("about to update display")
            self.update_display(board=pos.rotate().board)
            logging.debug("After updating display")
            self.query_sunfish(pos)

 Getting Sunfish to Move

This looks like a lot of code, but its structure is repetitive.  The first few lines parsing the location of the click are the same as in the original method.  I have added additional logic to test that the click is a legal move, and to identify legal moves for the click that will come next.  When a valid move has been made, that is fed, indirectly via the model, to the Sunfish engine which implements the move.  The system then asks Sunfish for a response (in the query_sunfish method) and, in the meantime, doesn’t respond to clicks on the board.

def query_sunfish(self, pos):
    logging.debug("in query sunfish")
    move, score = sunfish.search(pos)

    if score <= -sunfish.MATE_VALUE:
        logging.debug("You won")

    if score >= sunfish.MATE_VALUE:
        logging.debug("You lost")

    logging.debug("move = %s->%s"%move)
    pos= self.m.move(*move)
    self.update_display(board=pos.board)
    logging.debug(pos.board)
    self.click_state=SELECT_PIECE
    self.update_model()

Unfortunately, so far you can only tell if you’ve won or lost by watching the logs :(  But this is something that you can improve in your spare time – it wouldn’t be too hard to pop up a dialog box (look up tkMessageBox) and maybe also some code to initialise a new game.

The Model

The model has been completely replaced:

class Model(object):
    def __init__(self, *args):
        self.pos_list= [sunfish.Position(*args)]
        self.legal_moves = []

    def move(self,i,j):
        pos=self.pos_list[-1]
        self.pos_list.append(pos.move((i,j)))
        return self.pos_list[-1]
        # Sunfish returns a sunfish.Position object to us

It only had two methods anyway. Now they’re shorter. The move method assumes that the “from” and “to” board coordinates are given in Sunfish’s board location format. The model initialises a list, with the first entry being a Sunfish Position object with the board’s initial position. In fact, the model gives responsibility for updating the board and checking validity of moves to the Sunfish Position object. The model maintains a list of the board positions for each of the moves in the game. This could form the basis of a game review function later.

The full code is here.  It has some foibles – like allowing illegal king moves, not knowing draws and, at the moment, you can only play white.  These are all things that you can take on as challenges.  It also maintains a log which  you might want to turn off:

"""
Brendan Scott
Python4Kids.wordpress.com
November 2014

Chess -
attach a graphical interface to the sunfish chess engine

"""

import sunfish
import mvc2
import Tkinter as tk
import logging

fn  ="log_chess.log"  # Warning! Will delete any existing file called log_chess.log!
logging.basicConfig(filename= fn, filemode='w', level = logging.DEBUG, format= "%(levelname)s %(asctime)s %(funcName)s @%(lineno)d %(message)s")

SELECT_PIECE, MOVE_PIECE, WAIT_COMPUTER = range(3)

ALGEBRAIC_DATA=zip([0,1,2,3,4,5,6,7],[c for c in "hgfedcba"])
ALGEBRAIC_DICT = {}
for k,v in ALGEBRAIC_DATA:
    ALGEBRAIC_DICT[k] = v

logging.debug(ALGEBRAIC_DATA)
logging.debug(ALGEBRAIC_DICT)

def location_to_algebraic(board_location):
    return "%s%s"%(ALGEBRAIC_DICT[7-board_location.j],8-board_location.i)

# def move_to_algebraic(start, end):
#     return "%s%s"%(location_to_algebraic(start),location_to_algebraic(end))

class Model(object):
    def __init__(self, *args):
        self.pos_list= [sunfish.Position(*args)]
        self.legal_moves = []

    def move(self,i,j):
        pos=self.pos_list[-1]
        self.pos_list.append(pos.move((i,j)))
        return self.pos_list[-1]
        # Sunfish returns a sunfish.Position object to us

class NewController(mvc2.Controller):
    def __init__(self,  parent = None,  model = None, headless = False):
        self.headless = headless
        if model is None:
            self.m = Model(sunfish.initial, 0, (True,True), (True,True), 0, 0)
            #sunfish.Position(sunfish.initial, 0, (True,True), (True,True), 0, 0)
        else:
            self.m = model

        # logging.debug self.m.board
        self.v = mvc2.View(parent)
        ''' we have created both a model and a view within the controller
        the controller doesn't inherit from either model or view
        '''
        self.v.canvas.bind("<Button-1>",  self.handle_click)
        # this binds the handle_click method to the view's canvas for left button down

        self.clickList = []  #TODO: update to be sunfish pos not board location
        # I have kept clickList here, and not in the model, because it is a record of what is happening
        # in the view (ie click events) rather than something that the model deals with (eg moves).
        self.click_state = SELECT_PIECE
        self.update_model()

    def update_model(self):
        self.m.legal_moves = [m for m in self.m.pos_list[-1].genMoves()]
        self.m.valid_clicks = set([m[0] for m in self.m.pos_list[-1].genMoves()])

    def update_display(self,  debug_board=True, board=None):
        if self.headless:
            return
        if board is None:
            self.v.display(self.convert(self.m.pos_list[-1].board),  debug_board = debug_board)
        else:
            self.v.display(self.convert(board))
        self.v.canvas.update_idletasks()
        # sometimes tkinter is stalling, so flush instructions

    def convert(self, board):
        """
        Sunfish adds blank lines, spaces and carriage returns to its board for its processing
        however the code we are using to display this assumes that board
        is an array of string.  So, convert
        Also need to convert "." representing an empty square to " " for the current view
        :param board:
        :return:
        """

        b = board.split('\n')
        converted_board = []
        for line in b[2:-2]:  # sunfish has two blank lines at top and bottom
            if line[0]== " ":
                line = line[1:].replace("."," ")
                # sunfish also has left padding sometimes
            else:
                line = line.replace("."," ")
            converted_board.append([c for c in line])  # split each line
        return converted_board

    def handle_click(self,  event):
        ''' Handle a click received.  The x,y location of the click on the canvas is at
        (event.x, event.y)
        First, we need to translate the event coordinates (ie the x,y of where the click occurred)
        into a position on the chess board
        add this to a list of clicked positions
        every first click is treated as a "from" and every second click as a"to"
        so, whenever there are an even number of clicks, use the most recent to two to perform a move
        then update the display
        '''

        logging.debug("in handle click valid moves are: \n%s"%self.m.legal_moves)
        j = event.x/mvc2.TILE_WIDTH
        #  the / operator is called integer division
        # it returns the number of times TILE_WIDTH goes into event.x ignoring any remainder
        # eg: 2/2 = 1, 3/2 = 1, 11/5 = 2 and so on
        # so, it should return a number between 0 (if x < TILE_WIDTH) though to 7
        i = event.y/mvc2.TILE_WIDTH

        b_loc = mvc2.BoardLocation(i, j)

        # Select/deselect logic

        sunfish_pos =  sunfish.parse(location_to_algebraic(b_loc))
        logging.debug("received a click at %s"%sunfish_pos)

        if self.click_state == WAIT_COMPUTER:
            logging.debug("wait computer: Currently waiting for computer ignoring clicks")
            return

        elif self.click_state == SELECT_PIECE:
            logging.debug("valid clicks are: %s"%self.m.valid_clicks)
            if sunfish_pos in self.m.valid_clicks:
                self.clickList.append(sunfish_pos)
                logging.debug("select piece: piece selected at: %s"%(sunfish_pos))
                self.click_state = MOVE_PIECE
                self.m.valid_clicks = [m[1] for m in self.m.legal_moves if m[0] == sunfish_pos]
                logging.debug("valid clicks now: %s"%self.m.valid_clicks)
                return
            else:
                return

        else: #State is MOVE_PIECE
            logging.debug("move piece: valid clicks are: %s"%self.m.valid_clicks)
            if sunfish_pos == self.clickList[-1]:
                logging.debug("piece moved to: %s"%(sunfish_pos))
                # that is, the currently selected piece
                # unselect that piece
                self.clickList.pop()
                self.click_state = SELECT_PIECE
                self.m.valid_clicks = set([m[0] for m in self.m.legal_moves])
                return

            elif sunfish_pos not in self.m.valid_clicks:
                # wait for a valid move
                return
            else:
                self.clickList.append(sunfish_pos)
                self.click_state = WAIT_COMPUTER
                # execute the move:
                pos = self.m.move(self.clickList[-2], self.clickList[-1])
                logging.debug(repr(pos.board))
                logging.debug("about to update display")
                self.update_display(board=pos.rotate().board)
                logging.debug("After updating display")
                self.query_sunfish(pos)

    def query_sunfish(self, pos):
        logging.debug("in query sunfish")
        move, score = sunfish.search(pos)

        if score <= -sunfish.MATE_VALUE:
            logging.debug("You won")

        if score >= sunfish.MATE_VALUE:
            logging.debug("You lost")

        logging.debug("move = %s->%s"%move)
        pos= self.m.move(*move)
        self.update_display(board=pos.board)
        logging.debug(pos.board)
        self.click_state=SELECT_PIECE
        self.update_model()

if __name__ == "__main__":
    parent = tk.Tk()
    c = NewController(parent)
    c.run(debug_mode= False)

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: