Title: Make a tic-tac-toe game in Python
This example is pretty long. Much of it is user interface code, however, so I'll skip that and just cover the key points. You can download the example to look at the user interface details.
The following sections describe the program's key pieces.
new_game
There are a few ways you can start a new game. The game starts with a game in progess with the player being X so the program is waiting for the player to move. You can also start a new game by by selecting the File menu's Play X or Play O command. Finally, when a game finishes, the program asks if you want to play again and, if you click Yes, the program starts a new game.
In all of those cases, the following new_game method starts the new game.
def new_game(self, player_char):
'''Start a new game.'''
print(f'New game: player is {player_char}')
self.player_char = player_char
self.computer_char = 'O' if player_char == 'X' else 'X'
self.num_squares_taken = 0
self.game_over = False
self.computer_moves_next = self.computer_char == 'X'
# Reset the board.
self.reset_board()
# If it's the computer's turn, let it move.
if self.computer_moves_next:
self.computer_move()
This code resets some variables that the program uses to keep track of what's happening. It saves the player's character (X or O) and sets the computer's character to the other character (O or X).
The code sets num_squares_taken to 0 so we know that no squares have yet been taken. It sets game_over to False and sets computer_moves_next to True if the computer should move first.
Next, the method calls the reset_board method described next. Finally, if the computer should move, the program calls computer_move to let it move.
The following code shows the reset_board method.
def reset_board(self):
'''Reset the board.'''
for r in range(3):
for c in range(3):
self.squares[r][c]['text'] = ''
self.squares[r][c].config(bg=self.label_bg)
The program uses Label widgets to display the Xs and Os on the board. It stores them in a two-dimensional array (list of lists) so self.squares[r][c] is the label in row r and column c.
The reset_board method loops through the labels, sets their text to blank strings, and sets their background colors to the default color that they began with when the program started.
label_click
When it's the player's turn to move, the program just sits there waiting for the player to click a label. In a more complicated game like chess or Go, the program could use this time to search for good moves.
When the player clicks a label, the following code executes.
def label_click(self, label):
# Do nothing if the square is already taken.
if label['text'] != '': return
# Take this square for the player.
self.num_squares_taken += 1
label['text'] = self.player_char
# The computer moves next.
# (Unless overidden by check_for_winner when we start a new game.)
self.computer_moves_next = True
# See if there is a winner.
self.check_for_winner()
# If the game is ending, do nothing.
if self.game_over: return
# If it's the computer's turn, make it move.
if self.computer_moves_next:
self.computer_move()
If the clicked label is already taken, this method just returns.
Next, the code increments num_squares_taken and sets the label's text to the player's character (X or O). It then sets computer_moves_next to True so, if the game doesn't end, the computer will move next.
The code then calls check_for_winner (described shortly) to see if the player has won. In that case, check_for_winner sets game_over to True so this method returns.
If the game is not over and it's the computer's turn, the code calls computer_move (described later) to make the computer move. After that, the program goes back to waiting for the player to click another label.
check_for_winner
The following check_for_winner method determines whether the game is over.
def check_for_winner(self):
'''See if the game is over.'''
wins = [
# Check columns.
[0, 0, 1, 0],
[0, 1, 1, 0],
[0, 2, 1, 0],
# Check rows.
[0, 0, 0, 1],
[1, 0, 0, 1],
[2, 0, 0, 1],
# Check diagonals.
[0, 0, 1, 1],
[2, 0, -1, 1],
]
for win in wins:
winner = self.row_col_winner(*win)
if winner is not None:
# Highlight the win and play a fanfare.
self.highlight_win(*win)
if winner == self.player_char:
winsound.PlaySound('tada.wav', winsound.SND_ASYNC)
else:
winsound.PlaySound('lose.wav', winsound.SND_ASYNC)
# Display a victory message and ask if we should plan again.
if tk.messagebox.askyesno(f'{winner} wins!',
f'{winner} wins! Do you want to play again?'):
# Start a new game,
self.game_over = True
self.new_game(self.player_char)
else:
# Stop.
self.kill_callback()
return
# See if it's a cat's game.
if self.num_squares_taken == 9:
# Cat's game.
winsound.PlaySound('meow.wav', winsound.SND_ASYNC)
if tk.messagebox.askyesno('Cat\'s Game',
'It\'s a tie! Do you want to play again?'):
# Start a new game,
self.game_over = True
self.new_game(self.player_char)
else:
# Stop.
self.kill_callback()
return
The code first creates a list of possible wins. Each entry gives the row and column of a square together with values to use to increment the row and column numbers to move through the game board. For example, the first entry is [0, 0, 1, 0]. That means you start at row 0, column 0. You then increment the row by 1 and the column by 0 to move through the board. For this solution, you visit the squares [0][0], [1][0], and [2][0] so you're checking the leftmost column.
For each of these possible wins, the program calls row_col_winner (describe dnext) to see if that solution gives a win. If it does, the program plays an appropriate sound and displays a message box asking whether the player wants to play again.
If the user clicks Yes, the code sets game_over to True and calls new_game to start a new game.
If the user clicks No, the code calls kill_callback just as if the user had closed the program.
If none of the rows, columns, or diagonals gives a win, the code checks to see if all of the squares are taken. If so, the game ends in a tie (cat's game) so the code plays an appropriate sound.
The following row_col_winner method returns the character (X or O) of the player who has taken all three squares wins in this row, column, or diagonal.
def row_col_winner(self, row, col, dr, dc):
'''Return the row/column winner if there is one.'''
winner = self.squares[row][col]['text']
if winner == '':
return None
for i in range(1, 3):
r = row + i * dr
c = col + i * dc
if self.squares[r][c]['text'] != winner:
return None
return winner
This method gets the text in the label at the solution's starting position. If that label is blank, then they three squares are not all Xs or Os, so the method returns None.
If the start square isn't blank, the code loops through the other two squares in this row, column, or diagonal. If either of those squares holds text different from the start square, the method returns None.
If all three squares hold the same text, the method returns their text.
highlight_win
The following method highlights the winning squares by turning their background yellow.
def highlight_win(self, row, col, dr, dc):
for i in range(3):
r = row + i * dr
c = col + i * dc
self.squares[r][c].config(bg='yellow')
computer_move
This is the most interesting part of the program from a game programming standpoint. It's where the program uses its game logic to make a move.
This program provides three skill levels, which you can select by using the Skill Level menu. The following code shows how the program orchestrates those levels.
def computer_move(self):
# See if the board is full.
if self.num_squares_taken == 9: return
self.num_squares_taken += 1
if self.level_var.get() == 1:
self.computer_move_level_1()
elif self.level_var.get() == 2:
self.computer_move_level_2()
else:
self.computer_move_level_3()
# The player moves next.
# (Unless overidden by check_for_winner when we start a new game.)
self.computer_moves_next = False
# See if the game is over.
self.check_for_winner()
This method first checks whether the board is full and, if it is, the method simply returns.
Next, the code increments num_squares_taken. Then it checks the game level and calls one of the computer move methods described shortly to actually make a move.
After moving, the method sets computer_moves_next to False to indicate that it's the player's turn. It then calls check_for_winner to see if the computer has won.
If the play level is 1, the program uses the following code to make a random move.
def computer_move_level_1(self):
# Make a list of possible moves.
moves = self.get_possible_moves()
# Pick a random move.
row, col = random.choice(moves)
self.squares[row][col]['text'] = self.computer_char
This method calls get_possible_moves to make a list holding the rows and columns of all of the squares that are blank. It then uses random.choice to pick one at random and it sets that square's text to the computer's character.
The following get_possible_moves method builds a list of all possible moves.
def get_possible_moves(self):
'''Return a list of legal moves.'''
moves = []
for r in range(3):
for c in range(3):
if self.squares[r][c]['text'] == '':
moves.append([r, c])
return moves
If the skill level is set to 2 or 3, the program uses the following code to call the computer_move_level_1 method to make a random move.
def computer_move_level_2(self):
self.computer_move_level_1()
def computer_move_level_3(self):
self.computer_move_level_1()
In later posts, I'll replace these methods with something more advanced. If you like, you can try to implement these methods. They should do the following.
- computer_move_level_2 - Here the computer takes the best move given the current board position.
- If the computer can win in one move, it should take that move.
- Otherwise, if the computer can block a win by the player, it should do so.
- Otherwise, the computer should move randomly.
- computer_move_level_3 - Here the computer should examine all possible moves and pick the one that minimizes the best possible result for the player.
- If the computer can force the player to eventually lose, it should make that move.
- Otherwise, if the computer can force an eventual cat's game, it should make that move.
- Otherwise, the computer should move randomly.
The Level 3 strategy is called minimax because you minimize the maximum value that your opponent can get. You can try to do this by studying the patterns that occur in tic-tac-toe games if you like, but I wouldn't spend a huge amount of time working in that direction. For a simple game like tic-tac-toe, it's easier to examine every possible move. A pattern matching strategy is more important for harder games like chess, Go, and reversi where the number of possible moves is too large to search exhaustively.
Download the example to play the game, look at the rest of the code, and try to implement Skill Levels 2 and 3. I'll try to post the next version with Skill Level 2 in the next few days.
|