Reversi
The bool() Function
Remember how you could use the int() and str() functions to get the integer and string value of other data types? For example, str(42) would return the string '42', and int('100') would return the integer 100.
There is a similar function for the Boolean data type, bool(). Most other data types have one value that is considered the False value for that data type, and every other value is consider True. The integer 0, the floating point number 0.0, the empty string, the empty list, and the empty dictionary are all considered to be False when used as the condition for an if or loop statement. All other values are True. Try entering the following into the interactive shell:
>>> bool(0) False >>> bool(0.0) False >>> bool('') False >>> bool([]) False >>> bool({}) False >>> bool(1) True >>> bool('Hello') True >>> bool([1, 2, 3, 4, 5]) True >>> bool({'spam':'cheese', 'fizz':'buzz'}) True >>>
Whenever you have a condition, imagine that the entire condition is placed inside a call to bool() as the parameter. Conditions are automatically interpreted as Boolean values. This is similar to how print() can be passed non-string values and will automatically interpret them as strings when they print.
This is why the condition on line 111 works correctly. The call to the isValidMove() function either returns the Boolean value False or a non-empty list. If you imagine that the entire condition is placed inside a call to bool(), then False becomes bool (False) (which, of course, evalutes to False). And a non-empty list placed as the parameter to bool() will return True. This is why the return value of isValidMove() can be used as a condition.
Getting the Score of the Game Board
116. def getScoreOfBoard(board): 117. # Determine the score by counting the tiles. Returns a dictionary with keys 'X' and 'O'. 118. xscore = 0 119. oscore = 0 120. for x in range(8): 121. for y in range(8): 122. if board[x] [y] == 'X': 123. xscore += 1 124. if board[x] [y] == 'O': 125. oscore += 1 126. return {'X':xscore, 'O':oscore}
The getScoreOfBoard() function uses nested for loops to check all 64 spaces on the board (8 rows times 8 columns per row is 64 spaces) and see which tile (if any) is on them. For each 'X' tile, the code increments xscore. For each 'O' tile, the code increments oscore.
Notice that this function does not return a two-item list of the scores. A two-item list might be a bit confusing, because you may forget which item is for X and which item is for O. Instead the function returns a dictionary with keys 'X' and 'O' whose values are the scores.
Getting the Player's Tile Choice
129. def enterPlayerTile(): 130. # Let's the player type which tile they want to be. 131. # Returns a list with the player's tile as the first item, and the computer's tile as the second. 132. tile = ' ' 133. while not (tile == 'X' or tile == 'O'): 134. print('Do you want to be X or O?') 135. tile = input().upper()
This function asks the player which tile they want to be, either 'X' or 'O'. The for loop will keep looping until the player types in 'X' or 'O'.
137. # the first element in the tuple is the player's tile, the second is the computer's tile. 138. if tile == 'X' : 139. return ['X', 'O'] 140. else: 141. return ['O', 'X']
The enterPlayerTile() function then returns a two-item list, where the player's tile choice is the first item and the computer's tile is the second. We use a list here instead of a dictionary so that the assignment statement calling this function can use the multiple assignment trick. (See line 252.)
Determining Who Goes First
144. def whoGoesFirst(): 145. # Randomly choose the player who goes first. 146. if random.randint(0, 1) == 0: 147. return 'computer' 148 . else: 149. return 'player'
The whoGoesFirst() function randomly selects who goes first, and returns either the string 'computer' or the string 'player'.
Asking the Player to Play Again
152. def playAgain() : 153. # This function returns True if the player wants to play again, otherwise it returns False. 154. print('Do you want to play again? (yes or no)') 155. return input().lower().startswith('y')
We have used the playAgain() in our previous games. If the player types in something that begins with 'y', then the function returns True. Otherwise the function returns False.
Placing Down a Tile on the Game Board
158. def makeMove(board, tile, xstart, ystart): 159. # Place the tile on the board at xstart, ystart, and flip any of the opponent's pieces. 160. # Returns False if this is an invalid move, True if it is valid. 161. tilesToFlip = isValidMove(board, tile, xstart, ystart)
makeMove() is the function we call when we want to place a tile on the board and flip the other tiles according to the rules of Reversi. This function modifies the board data structure that is passed as a parameter directly. Changes made to the board variable (because it is a list) will be made to the global scope as well. Most of the work is done by isValidMove(), which returns a list of XY coordinates (in a two-item list) of tiles that need to be flipped. (Remember, if the the xstart and ystart arguments point to an invalid move, then isValidMove() will return the Boolean value False.)
163. if tilesToFlip == False: 164. return False 165 . 166. board[xstart] [ystart] = tile 167. for x, y in tilesToFlip: 168. board[x] [y] = tile 169. return True
If the return value of isValidMove() was False, then makeMove() will also return False.
Otherwise, isValidMove() would have returned a list of spaces on the board to put down our tiles (the 'X' or 'O' string in tile). Line 166 sets the space that the player has moved on, and the for loop after that sets all the tiles that are in tilesToFlip.
Copying the Board Data Structure
172. def getBoardCopy(board): 173. # Make a duplicate of the board list and return the duplicate. 174. dupeBoard = getNewBoard() 175. 176. for x in range(8): 177. for y in range(8): 178. dupeBoard[x] [y] = board[x] [y] 179 . 180. return dupeBoard
getBoardCopy() is different from getNewBoard(). getNewBoad() will create a new game board data structure which has only empty spaces. getBoardCopy() will create a new game board data structure, but then copy all of the pieces in the board parameter. This function is used by our AI to have a game board that it can change around without changing the real game board. This is like how you may imagine making moves on a copy of the board in your mind, but not actually put pieces down on the real board.
A call to getNewBoard() handles getting a fresh game board data structure. Then the nested for loops copies each of the 64 tiles from board to our duplicate board, dupeBoard.
Determining if a Space is on a Corner
183. def isOnCorner(x, y): 184. # Returns True if the position is in one of the four corners . 185. return (x == 0 and y == 0) or (x == 7 and y == 0) or (x == 0 and y == 7) or (x == 7 and y == 7)
This function is much like isOnBoard(). Because all Reversi boards are 8 x 8 in size, we only need the XY coordinates to be passed to this function, not a game board data structure itself. This function returns True if the coordinates are on either (0,0), (7,0), (0,7) or (7,7). Otherwise isOnCorner() returns False.
Getting the Player's Move
188. def getPlayerMove(board, playerTile): 189. # Let the player type in their move. 190. # Returns the move as [x, y] (or returns the strings 'hints' or 'quit') 191. DIGITS1TO8 = '1 2 3 4 5 6 7 8'.split()
The getPlayerMove() function is called to let the player type in the coordinates of their next move (and check if the move is valid). The player can also type in 'hints' to turn hints mode on (if it is off) or off (if it is on). The player can also type in 'quit' to quit the game.
The DIGITS1TO8 constant variable is the list ['1', '2', '3', '4', '5', '6', '7', '8'] . We create this constant because it is easier type DIGITS1TO8 than the entire list.
192. while True: 193. print('Enter your move, or type quit to end the game, or hints to turn off/on hints.') 194. move = input().lower() 195. if move == 'quit': 196. return 'quit' 197. if move == 'hints': 198. return 'hints'
The while loop will keep looping until the player has typed in a valid move. First we check if the player wants to quit or toggle hints mode, and return the string 'quit' or 'hints'. We use the lower() method on the string returned by input() so the playe can type 'HINTS' or 'Quit' but still have the command understood by our game.
The code that calls getPlayerMove() will handle what to do if the player wants to quit or toggle hints mode.
200. if len(move) == 2 and move[0] in DIGITS1TO8 and move[1] in DIGITS1TO8: 201. x = int(move[0]) - 1 202. y = int(move[1]) - 1 203. if isValidMove(board, playerTile, x, y) == False: 204. continue 205. else: 206. break
Our game is expecting that the player would have typed in the XY coordinates of their move as two numbers without anything in between them. The if statement first checks that the size of the string the player typed in is 2. After that, the if statement also checks that both move[0] (the first character in the string) and move[1] (the second character in the string) are strings that exist in DIGITS1TO8, which we defined at the beginning of the function.
Remember that our game board data structures have indexes from 0 to 7, not 1 to 8. We show 1 to 8 when we print the board using drawBoard() because people are used to numbers beginning at 1 instead of 0. So when we convert the strings in move[0] and move[1] to integers, we also subtract 1.
Even if the player typed in a correct move, we still need to check that the move is allowed by the rules of Reversi. We do this by calling isValidMove(), passing the game board data structure, the player's tile, and the XY coordinates of the move. If isValidMove() returns False, then we execute the continue statement so that the flow of execution goes back to the beginning of the while loop and asks the player for the move again.
If isValidMove() does not return False, then we know the player typed in a valid move and we should break out of the while loop.
207. else: 208. print('That is not a valid move. Type the x digit (1-8) , then the y digit (1-8) . ' ) 209. print('For example, 81 will be the top-right corner. ' )
If the if statement's condition on line 200 was False, then the player did not type in a valid move. We should display a message instructing them how to type in moves that our Reversi program can understand. Afterwards, the execution moves back to the while statement on line 192 because line 209 is not only the last line in the else-block, but also the last line in the while-block.
211. return [x, y]
Finally, getPlayerMove() returns a two-item list with the XY coordinates of the player's valid move.
Getting the Computer's Move
214. def getComputerMove(board, computerTile): 215. # Given a board and the computer's tile, determine where to 216. # move and return that move as a [x, y] list. 217. possibleMoves = getValidMoves(board, computerTile)
getComputerMove() and is where our Reversi AI is implemented. The getValidMoves() function is very helpful for our AI. Normally we use the results from getValidMoves() for hints move. Hints mode will print '.' period characters on the board to show the player all the potential moves they can make. But if we call getValidMoves() with the computer AI's tile (in computerTile), we can get all the possible moves that the computer can make. We will select the best move from this list.
219. # randomize the order of the possible moves 220. random.shuffle(possibleMoves)
First, we are going to use the random.shuffle() function to randomize the order of moves in the possibleMoves list. Remember that the random.shuffle() function will reorder the items in the list that you pass to it. The function also modifies the list directly, much like our resetBoard() function does with the game board data structure.
We will explain why we want to shuffle the possibleMoves list, but first let's look at our algorithm.
Corner Moves are the Best Moves
222. # always go for a corner if available. 223. for x, y in possibleMoves: 224. if isOnCorner(x, y): 225. return [x, y]
First, we loop through every move in possibleMoves and if any of them are on the corner, we return that as our move. Corner moves are a good idea because once a tile has been placed on the corner, it can never be flipped over. Since possibleMoves is a list of two-item lists, we use the multiple assignment trick in our for loop to set x and y.
Because we immediately return on finding the first corner move in possibleMoves, if possibleMoves contains multiple corner moves we always go with the first one. But since possibleMoves was shuffled on line 220, it is completely random which corner move is first in the list.
Get a List of the Best Scoring Moves
227. # Go through all the possible moves and remember the best scoring move 228. bestScore = -1 229. for x, y in possibleMoves: 230. dupeBoard = getBoardCopy(board) 231. makeMove(dupeBoard, computerTile, x, y) 232. score = getScoreOfBoard(dupeBoard)[computerTile] 233. if score > bestScore: 234. bestMove = [x, y] 235. bestScore = score 236. return bestMove
If there are no corner moves, we will go through the entire list and find out which move gives us the highest score. The for loop will set x and y to every move in possibleMoves. bestMove will be set to the highest scoring move we've found so far, and bestScore will be set to the best move's score. When the code in the loop finds a move that scores higher than bestScore, we will store that move and score as the new values of bestMove and bestScore.
Simulate All Possible Moves on Duplicate Board Data Structures
In order to figure out the score of the possible move we are currently iterating on, we first make a duplicate game board data structure by calling getBoardCopy(). We want a copy so we can modify without changing the real game board data structure stored in the board variable.
Then we call makeMove(), passing the duplicate board instead of the real board. makeMove() will handle placing the computer's tile and the flipping the player's tiles on the duplicate board.
We call getScoreOfBoard() with the duplicate board, which returns a dictionary where the keys are 'X' and 'O', and the values are the scores. getScoreOfBoard() does not know if the computer is 'X' or 'O', which is why it returns a dictionary.
By making a duplicate board, we can simulate a future move and test the results of that move without changing the actual game board data structure. This is very helpful in deciding which move is the best possible move to make.
Pretend that getScoreOfBoard() returns the dictionary {'X':22, 'O':8} and computerTile is 'X'. Then getScoreOfBoard(dupeBoard) [computerTile] would evaluate to {'X':22, 'O':8}['X'], which would then evaluate to 22. If 22 is larger than bestScore, bestScore is set to 22 and bestMove is set to the current x and y values we are looking at. By the time this for loop is finished, we can be sure that bestScore is the highest possible score a move can make, and that move is stored in bestMove.
You may have noticed that on line 228 we first set bestScore to -1. This is so that the first move we look at in our for loop over possibleMoves will be set to the first bestMove. This will guarantee that bestMove is set to one of the moves when we return it.
Say that the highest scoring move in possibleMoves would give the computer a score of 42. What if there was more than one move in possibleMoves that would give this score? The for loop we use would always go with the first move that scored 42 points, because bestMove and bestScore only change if the move is greater than the highest score. A tie will not change bestMove and bestScore.
We do not always want to go with the first move in the possibleMoves list, because that would make our AI predictable by the player. But it is random, because on line 220 we shuffled the possibleMoves list. Even though our code always chooses the first of these tied moves, is random which of the moves will be first in the list because the order is random. This ensures that the AI will not be predictable when there is more than one best move.