Tic Tac Toe GUI

This section will show how to create a GUI for Tic Tac Toe using tkinter. User will play against the computer. To keep it simple, computer moves will be just a random selection. Smarter computer move will be discussed in a later section.

Layout

There are several ways to prepare before you start coding your GUI. Creating a rough sketch of how your GUI should look with pen and paper is often recommended. Here's one possible list of requirements:

  • Options to choose who moves first
  • Quit (close the window, optional)
  • Start a game/play again
  • Display information about the current game status
  • The game board

Both grid() and pack() layout techniques will be used here. You cannot mix different layout methods, but you can use different frames to group and isolate widgets based on layout requirements.

Code

# tic_tac_toe.py
import random
import tkinter as tk

class Root(tk.Tk):
    def __init__(self):
        super().__init__()

        self.title('Tic Tac Toe')
        self.geometry('500x400')

        self.char_x = tk.PhotoImage(file='./char_x.png')
        self.char_o = tk.PhotoImage(file='./char_o.png')
        self.empty = tk.PhotoImage()

        self.active = 'GAME ACTIVE'
        self.total_cells = 9
        self.line_size = 3
        self.computer = {'value': 1, 'bg': 'orange',
                         'win': 'COMPUTER WINS', 'image': self.char_x}
        self.user = {'value': self.line_size+1, 'bg': 'grey',
                     'win': 'USER WINS', 'image': self.char_o}
        self.board_bg = 'white'
        self.all_lines = ((0, 1, 2), (3, 4, 5), (6, 7, 8),
                          (0, 3, 6), (1, 4, 7), (2, 5, 8),
                          (0, 4, 8), (2, 4, 6))

        self.create_radio_frame()
        self.create_control_frame()

    def create_radio_frame(self):
        self.radio_frame = tk.Frame()
        self.radio_frame.pack(side=tk.TOP, pady=5)

        tk.Label(self.radio_frame, text='First Move').pack(side=tk.LEFT)
        self.radio_choice = tk.IntVar()
        self.radio_choice.set(self.user['value'])
        tk.Radiobutton(self.radio_frame, text='Computer',
                       variable=self.radio_choice, value=self.computer['value']
                      ).pack(side=tk.LEFT)
        tk.Radiobutton(self.radio_frame, text='User',
                       variable=self.radio_choice, value=self.user['value']
                      ).pack(side=tk.RIGHT)

    def create_control_frame(self):
        self.control_frame = tk.Frame()
        self.control_frame.pack(side=tk.TOP, pady=5)

        self.b_quit = tk.Button(self.control_frame, text='Quit',
                                command=self.quit)
        self.b_quit.pack(side=tk.LEFT)

        self.b_play = tk.Button(self.control_frame, text='Play',
                                command=self.play)
        self.b_play.pack(side=tk.RIGHT)

    def create_status_frame(self):
        self.status_frame = tk.Frame()
        self.status_frame.pack(expand=True)

        tk.Label(self.status_frame, text='Status: ').pack(side=tk.LEFT)
        self.l_status = tk.Label(self.status_frame)
        self.l_status.pack(side=tk.RIGHT)

    def create_board_frame(self):
        self.board_frame = tk.Frame()
        self.board_frame.pack(expand=True)

        self.cell = [None] * self.total_cells
        self.board = [0] * self.total_cells
        self.remaining_moves = list(range(self.total_cells))
        for i in range(self.total_cells):
            self.cell[i] = tk.Label(self.board_frame, highlightthickness=1,
                                    width=75, height=75, bg=self.board_bg,
                                    image=self.empty)
            self.cell[i].bind('<Button-1>',
                              lambda e, move=i: self.user_click(e, move))
            r, c = divmod(i, self.line_size)
            self.cell[i].grid(row=r, column=c)

    def play(self):
        self.b_play['state'] = 'disabled'
        if self.b_play['text'] == 'Play':
            self.create_status_frame()
            self.b_play['text'] = 'Play Again'
        else:
            self.board_frame.destroy()
        self.l_status['text'] = self.active
        self.state = self.active
        self.last_click = 0
        self.create_board_frame()
        if self.radio_choice.get() == self.computer['value']:
            self.computer_click()

    def quit(self):
        self.destroy()

    def user_click(self, e, user_move):
        if self.board[user_move] != 0 or self.state != self.active:
            return
        self.update_board(self.user, user_move)
        if self.state == self.active:
            self.computer_click()

    def computer_click(self):
        computer_move = random.choice(self.remaining_moves)
        self.update_board(self.computer, computer_move)

    def update_board(self, player, move):
        self.board[move] = player['value']
        self.remaining_moves.remove(move)
        self.cell[self.last_click]['bg'] = self.board_bg
        self.last_click = move
        self.cell[move]['image'] = player['image']
        self.cell[move]['bg'] = player['bg']
        self.update_status(player)
        self.l_status['text'] = self.state
        if self.state != self.active:
            self.b_play['state'] = 'normal'

    def update_status(self, player):
        winner_sum = self.line_size * player['value']
        for line in self.all_lines:
            if sum(self.board[i] for i in line) == winner_sum:
                self.state = player['win']
                self.highlight_winning_line(player, line)
        if self.state == self.active and not self.remaining_moves:
            self.state = 'TIE'

    def highlight_winning_line(self, player, line):
        for i in line:
            self.cell[i]['bg'] = player['bg']

if __name__ == '__main__':
    root = Root()
    root.mainloop()

Explanation for frames

  • Initial screen shows two frames at the top — radio and control.
    • User gets to play the first move by default, which can be changed by choosing the Computer option.
    • Quit button is active all the time, allows the user to close the application.
    • Play button is responsible for creating a new game. After the first click, the text changes to Play Again.
  • The status frame holds two labels to indicate the current state of the game. This becomes visible when the Play button is clicked.
    • There are three states — GAME ACTIVE, TIE and victory for one of the players (COMPUTER WINS or USER WINS).
  • The board frame creates the grid of labels representing the game area. This becomes visible when the Play button is clicked.
    • Left button click for each cell is handled by the user_click() method.

The side=tk.TOP option sets the top position for radio and control frames. This is chosen since the other two frames are created only after the Play button is clicked. If you prefer, you can choose to add a help text about the game rules when the application is first launched.

Explanation for variables

  • state tracks the current state of the game. You could technically use the text parameter of the status label as well, but separate variable will help if you want to split the code into separate classes for game logic and UI.
  • total_cells and line_size don't have much use in this particular code, but it will help if you want to extend the game to support multiple board sizes.
  • computer and user dictionaries store player information. This allows methods like update_board() to work for both players based on which dictionary is passed as an argument.
  • all_lines stores indexes of all valid lines (8 lines in total for 3x3 board).
  • remaining_moves list keeps track of available moves. Whenever a user/computer move is made, that particular index is removed from this list.
  • board list keeps track of which player has made a move for a particular cell using the value key from the player's respective dictionary.
    • cell list is the equivalent for image labels.
  • last_click keeps track of which cell was last updated. Since there is no delay implemented for computer moves in this code, effectively you'll see only the last computer move highlighted. The only exception is if a game ends in a TIE and the last move was made by the user.

Explanation for game logic

  • Once the user clicks the Play button, the status and board frames show up.
    • Initial status shows game in active state.
    • For Play Again button, the old board frame is destroyed before creating the new board.
    • If Computer plays first was chosen, one computer move is made.
  • All computer moves are random in this particular project. Coding a smarter move will be discussed later.
  • When the user clicks one of the image labels:
    • return without further processing if the game is not active or if a valid move is already made on that cell.
    • Otherwise, the board is updated using the update_board() method.
    • Then, if the game is still active, another computer move is made.
  • update_board() method:
    • Based on the player dictionary and move index passed, the board, cell, last_click and remaining_moves variables are updated.
    • Once the move is completed, status is updated. If the game is no longer active, Play Again button's state is changed to normal.
  • update_status() method:
    • Game ends if one of the player wins or if all the cells have been clicked (resulting in a TIE).
    • Iterate over the all_lines tuple and calculate the sum of values of each index from a particular line.
      • If the sum equals 3 (i.e. line_size) times the player value, then it is a winning line.
      • highlight_winning_line method will highlight such a line by changing the background of all indexes for this line.
      • Note that there can be multiple winning lines.

Screenshots

Tic Tac Toe Computer Wins

Tic Tac Toe User Wins