Hooking up the Sunfish Chess Engine (Advanced)
December 2, 2014 Leave a comment
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)