Tic-tac-toe is a popular two player game where each player tries to occupy an empty slot in a 3x3 board until one of them wins or the entire board is filled without any winner. The winner has to occupy three continuous cells in any direction, including the two diagonals. In this article, a version of the tic-tac-toe game is coded using Python and tkinter library.
Environment
The source code is written and executed in the following environment,
- Operating System - Windows 11
- Interpreter - Python 3.10.1
- Editor - Visual Studio code
The game board can be seen as a square which contains a total of 9 cells. The code in the article is designed with the following specifications, also shown visually in the image below,
- Window size - 650x650 pixels
- Cell Size - 150x150 pixels
- Board Size - 450x450 pixels
- Padding around board - 100 pixels on each direction
The code uses tkinter and tkinter.ttk libraries. Apart from these libraries, Enum is used for defining a set of constants and choice is used in AI decision to occupy an empty cell in the board. A global variable is used to hold the current game state.
from enum import Enum from random import choice from tkinter import * from tkinter import ttk from tkinter import messagebox gameState = None
Instead of hard coded values, the code defines a few useful constants for using Enum. These constants include,
- various sizes related to the cells and window
- font and font sizes
- markers and turn indicators for player and AI
class Const(Enum): OFFSET = 100 SIDE = 150 MID = 75 FONT = "Comic Sans MS" CELL_FONT_SIZE = 56 LABEL_FONT_SIZE = 16 WINSIZE = 650 ROWCOL = 3 PLAYER_CHAR = "X" AI_CHAR = "O" EMPTY_CHAR = "0" TURN_PLAYER = 0 TURN_CPU = 1
The first class defined is the Point class, which is used to store a (x, y) value which lies within the game window. It also defines a method which creates a new object with offset applied to both x and y values. The add() method is used later to calculate the mid point of a cell.
class Point: def __init__(self, x, y, offset=0): self.x = x + offset self.y = y + offset def add(self, offset): return Point(self.x + offset, self.y + offset)
The next class is the Cell class, which holds data related to one cell in the game board. An array of objects are created later to represent the entire game board. The Cell class exports quite a few methods for operation, each method is discussed briefly,
Constructor
Cells are arranged in a 2D format with indices available to both row and column. Event though there are 9 cells, all these cells are represented visually within a single canvas. So the constructor expects a canvas argument along with the row and column values. While the row and column values are not stored in the object, it is used to calculate the start and mid-point of the cell. The cell object also holds an id value to the text that will be created during the game, the id is required later for deleting the character from the canvas. Each cell is initially empty, so a couple of fields to indicate that the cell is empty.
mark() method
The mark() method creates the visual mark on the game board, the method can mark the cell only if it is empty, it can mark the cell for either the player or AI. There are two wrapper methods markPlayer() and markAI() coded for better clarity. The current marker can be obtained using the getMarker() method, the marker needs to be retrieved at various stages to game logic.
unmark() method
The unmark() method removes the current marker and marks the cell as empty again.
class Cell: def __init__(self, canvas, i, j): self.canvas = canvas self.start = Point(i * Const.SIDE.value, j * Const.SIDE.value, offset=Const.OFFSET.value) self.mid = self.start.add(Const.MID.value) self.textId = 0 self.marked = False self.marker = Const.EMPTY_CHAR.value def mark(self, char): if self.marked: return False self.marker = char self.textId = self.canvas.create_text( self.mid.x, self.mid.y, text=self.marker, font=(Const.FONT.value, Const.CELL_FONT_SIZE.value)) self.marked = True return True def markPlayer(self): return self.mark(Const.PLAYER_CHAR.value) def markAI(self): return self.mark(Const.AI_CHAR.value) def unmark(self): if not self.marked: return False self.canvas.delete(self.textId) self.marked = False self.marker = Const.EMPTY_CHAR.value return True def getMarker(self): return self.marker
Then comes the Score class, which keeps track of the number of games won by both the player and the AI. The object has two labels for scores, one aligned to the left edge and the other aligned to the right edge of the board. The alignment is done using the place() geometry manager of tkinter library. The scores are initially set to 0, and every time someone wins, the corresponding score gets incremented by 1, and the labels' text are updated.
class Score: def __init__(self, root): self.playerScore = 0 self.cpuScore = 0 self.playerLabel = ttk.Label(root, font=(Const.FONT.value, Const.LABEL_FONT_SIZE.value)) self.playerLabel.place(x=100, y=35, width=Const.SIDE.value) self.cpuLabel = ttk.Label(root, font=(Const.FONT.value, Const.LABEL_FONT_SIZE.value), anchor='e') self.cpuLabel.place(x=400, y=35, width=Const.SIDE.value) self.updateScore() def updateScore(self): self.playerLabel.config(text=f"You - {self.playerScore}") self.cpuLabel.config(text=f"Cpu - {self.cpuScore}") def playerWon(self): self.playerScore += 1 self.updateScore() def cpuWon(self): self.cpuScore += 1 self.updateScore()
The final and the largest class is the GameState class, which tracks the whole board, controls the AI, updates scores, reset games etc. All the methods part of this class are explained in detail below.
Constructor
The constructor creates the 3x3 two-dimensional array for the Cell objects, creates a Score object, draws the tic-tac-toe board and initially set the turn to the player. It takes a tkinter root element as argument, which is used to create the canvas.
setupCanvas() method
The canvas is created with the input tkinter root, and the tic-tac-toe board is drawn. For taking user input through the mouse, the left mouse button is bound to a callback.
playerSelected() method
The method is called when the player clicks on the canvas. If the click happened on a valid and empty cell, the cell is marked as occupied by the player.
clear() method
After the game ended, the clear() method is called with an optional winner argument. The entire board is marked as empty and scores are updated based on the winner. The scores are not incremented if the game is drawn. Also, the first turn is handed over to either the player or AI depending on the last round.
checkWinner() method
The end game state is calculated here, the code goes through all the rows, columns and diagonals to see if there is a winner. The winner is decided if three continuous cells are occupied in a row or column or one of the diagonals. The cells to check are input to a helper method check() to decide the winner. If no winner is found, the method returns False. There are wrapper methods to check win status for player and AI.
aiTurn() method
The AI is coded using a simple logic, it will occupy one of the un-occupied slots. There is a helper method getEmptySlots() which returns a list of row and column tuples, and a random tuple is chosen. The checkDrawn() method also makes use of getEmptySlots() method to decide whether the game is drawn. The game will be drawn when the board is full and there is no winner yet.
class GameState: def __init__(self, root): self.canvas = self.setupCanvas(root) self.cells = [[Cell(self.canvas, i, j) for i in range(Const.ROWCOL.value)] for j in range(Const.ROWCOL.value)] self.score = Score(root) self.turn = Const.TURN_PLAYER.value def setupCanvas(self, root): c = Canvas(root) c.place(x=0, y=0, height=Const.WINSIZE.value, width=Const.WINSIZE.value) c.create_line(250, 100, 250, 550, width=5, fill="#aaa") c.create_line(400, 100, 400, 550, width=5, fill="#aaa") c.create_line(100, 250, 550, 250, width=5, fill="#aaa") c.create_line(100, 400, 550, 400, width=5, fill="#aaa") c.bind("<Button-1>", mouseCb) return c def playerSelected(self, i, j): valid = lambda x : 0 <= x < Const.ROWCOL.value if not valid(i) or not valid(j): return False return self.cells[i][j].markPlayer() def clear(self, winner="none"): for i in range(Const.ROWCOL.value): for j in range(Const.ROWCOL.value): self.cells[i][j].unmark() match winner: case "player": self.score.playerWon() case "cpu": self.score.cpuWon() case "_": pass if self.turn == Const.TURN_PLAYER.value: self.turn = Const.TURN_CPU.value self.aiTurn() elif self.turn == Const.TURN_CPU.value: self.turn = Const.TURN_PLAYER.value def check(self, rowColPairs, marker): for (row, col) in rowColPairs: if (marker != self.cells[row][col].getMarker()): return False return True def getColPair(self, row): return [(row, i) for i in range(Const.ROWCOL.value)] def getRowPair(self, col): return [(i, col) for i in range(Const.ROWCOL.value)] def checkWinner(self, char): for row in range(Const.ROWCOL.value): if self.check(self.getColPair(row), char): return True for col in range(Const.ROWCOL.value): if self.check(self.getRowPair(col), char): return True # check diagonals if self.check([(0, 0), (1, 1), (2, 2)], char): return True if self.check([(0, 2), (1, 1), (2, 0)], char): return True return False def checkPlayerWin(self): return self.checkWinner(Const.PLAYER_CHAR.value) def checkCpuWin(self): return self.checkWinner(Const.AI_CHAR.value) def getEmptySlots(self): slots = [] for i in range(Const.ROWCOL.value): for j in range(Const.ROWCOL.value): if self.cells[i][j].getMarker() == Const.EMPTY_CHAR.value: slots.append((i, j)) return slots def checkDrawn(self): return len(self.getEmptySlots()) == 0 def aiTurn(self): slots = self.getEmptySlots() if len(slots) == 0: return (row, col) = choice(slots) self.cells[row][col].markAI()
The callback event handles the logic for occupying a cell for the player. If a cell is successfully occupied by the player then a win condition is checked for the player. If the player did not win, the AI makes its move and a win condition for the AI is checked. After every move, the game is also checked for a Draw state. The method also displays a message box for the winner or the drawn state.
def mouseCb(event): index = lambda x: (x - Const.OFFSET.value) // Const.SIDE.value j = index(event.x) i = index(event.y) if not gameState.playerSelected(i, j): return if gameState.checkPlayerWin(): messagebox.showinfo("You won", "You won the round.") gameState.clear(winner="player") return gameState.aiTurn() if gameState.checkCpuWin(): messagebox.showinfo("Cpu won", "Cpu won the round.") gameState.clear(winner="cpu") return if gameState.checkDrawn(): messagebox.showinfo("Game drawn", "The round has been drawn.") gameState.clear()
Finally, the main() function creates the tkinter root, the GameState object and runs the main loop.
def main(): global gameState global ai root = Tk() root.title("Tic-tac-toe") root.geometry(f"{Const.WINSIZE.value}x{Const.WINSIZE.value}+500+500") root.resizable(False, False) gameState = GameState(root) root.mainloop() if __name__ == "__main__": main()
The below image captures various screens from a sample run of the tic-tac-toe game.
Thanks for reading, please leave comments/suggestions if any.
Comments
Post a Comment