├── .gitignore ├── README.md ├── constants.py ├── snapshots ├── snapshot1.png ├── snapshot2.png └── snapshot3.png └── tictactoe.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/constants.cpython-310.pyc 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Game Instructions 2 | 3 | - press 'g' to change gamemode (pvp or ai) 4 | - press '0' to change ai level to 0 (random) 5 | - press '1' to change ai level to 1 (impossible) 6 | - press 'r' to restart the game 7 | 8 | # Game Snapshots 9 | 10 | ## Snapshot 1 - Start 11 | ![snapshot1](snapshots/snapshot1.png) 12 | 13 | ## Snapshot 2 - Circle Win 14 | ![snapshot2](snapshots/snapshot2.png) 15 | 16 | ## Snapshot 3 - Cross Win 17 | ![snapshot3](snapshots/snapshot3.png) 18 | -------------------------------------------------------------------------------- /constants.py: -------------------------------------------------------------------------------- 1 | # --------- 2 | # CONSTANTS 3 | # --------- 4 | 5 | # --- PIXELS --- 6 | 7 | WIDTH = 600 8 | HEIGHT = 600 9 | 10 | ROWS = 3 11 | COLS = 3 12 | SQSIZE = WIDTH // COLS 13 | 14 | LINE_WIDTH = 15 15 | CIRC_WIDTH = 15 16 | CROSS_WIDTH = 20 17 | 18 | RADIUS = SQSIZE // 4 19 | 20 | OFFSET = 50 21 | 22 | # --- COLORS --- 23 | 24 | BG_COLOR = (28, 170, 156) 25 | LINE_COLOR = (23, 145, 135) 26 | CIRC_COLOR = (239, 231, 200) 27 | CROSS_COLOR = (66, 66, 66) -------------------------------------------------------------------------------- /snapshots/snapshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlejoG10/python-tictactoe-ai-yt/c3d41dcf115a7b2bcf56e65594e0736843a260a2/snapshots/snapshot1.png -------------------------------------------------------------------------------- /snapshots/snapshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlejoG10/python-tictactoe-ai-yt/c3d41dcf115a7b2bcf56e65594e0736843a260a2/snapshots/snapshot2.png -------------------------------------------------------------------------------- /snapshots/snapshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlejoG10/python-tictactoe-ai-yt/c3d41dcf115a7b2bcf56e65594e0736843a260a2/snapshots/snapshot3.png -------------------------------------------------------------------------------- /tictactoe.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import sys 3 | import pygame 4 | import random 5 | import numpy as np 6 | 7 | from constants import * 8 | 9 | # --- PYGAME SETUP --- 10 | 11 | pygame.init() 12 | screen = pygame.display.set_mode( (WIDTH, HEIGHT) ) 13 | pygame.display.set_caption('TIC TAC TOE AI') 14 | screen.fill( BG_COLOR ) 15 | 16 | # --- CLASSES --- 17 | 18 | class Board: 19 | 20 | def __init__(self): 21 | self.squares = np.zeros( (ROWS, COLS) ) 22 | self.empty_sqrs = self.squares # [squares] 23 | self.marked_sqrs = 0 24 | 25 | def final_state(self, show=False): 26 | ''' 27 | @return 0 if there is no win yet 28 | @return 1 if player 1 wins 29 | @return 2 if player 2 wins 30 | ''' 31 | 32 | # vertical wins 33 | for col in range(COLS): 34 | if self.squares[0][col] == self.squares[1][col] == self.squares[2][col] != 0: 35 | if show: 36 | color = CIRC_COLOR if self.squares[0][col] == 2 else CROSS_COLOR 37 | iPos = (col * SQSIZE + SQSIZE // 2, 20) 38 | fPos = (col * SQSIZE + SQSIZE // 2, HEIGHT - 20) 39 | pygame.draw.line(screen, color, iPos, fPos, LINE_WIDTH) 40 | return self.squares[0][col] 41 | 42 | # horizontal wins 43 | for row in range(ROWS): 44 | if self.squares[row][0] == self.squares[row][1] == self.squares[row][2] != 0: 45 | if show: 46 | color = CIRC_COLOR if self.squares[row][0] == 2 else CROSS_COLOR 47 | iPos = (20, row * SQSIZE + SQSIZE // 2) 48 | fPos = (WIDTH - 20, row * SQSIZE + SQSIZE // 2) 49 | pygame.draw.line(screen, color, iPos, fPos, LINE_WIDTH) 50 | return self.squares[row][0] 51 | 52 | # desc diagonal 53 | if self.squares[0][0] == self.squares[1][1] == self.squares[2][2] != 0: 54 | if show: 55 | color = CIRC_COLOR if self.squares[1][1] == 2 else CROSS_COLOR 56 | iPos = (20, 20) 57 | fPos = (WIDTH - 20, HEIGHT - 20) 58 | pygame.draw.line(screen, color, iPos, fPos, CROSS_WIDTH) 59 | return self.squares[1][1] 60 | 61 | # asc diagonal 62 | if self.squares[2][0] == self.squares[1][1] == self.squares[0][2] != 0: 63 | if show: 64 | color = CIRC_COLOR if self.squares[1][1] == 2 else CROSS_COLOR 65 | iPos = (20, HEIGHT - 20) 66 | fPos = (WIDTH - 20, 20) 67 | pygame.draw.line(screen, color, iPos, fPos, CROSS_WIDTH) 68 | return self.squares[1][1] 69 | 70 | # no win yet 71 | return 0 72 | 73 | def mark_sqr(self, row, col, player): 74 | self.squares[row][col] = player 75 | self.marked_sqrs += 1 76 | 77 | def empty_sqr(self, row, col): 78 | return self.squares[row][col] == 0 79 | 80 | def get_empty_sqrs(self): 81 | empty_sqrs = [] 82 | for row in range(ROWS): 83 | for col in range(COLS): 84 | if self.empty_sqr(row, col): 85 | empty_sqrs.append( (row, col) ) 86 | 87 | return empty_sqrs 88 | 89 | def isfull(self): 90 | return self.marked_sqrs == 9 91 | 92 | def isempty(self): 93 | return self.marked_sqrs == 0 94 | 95 | class AI: 96 | 97 | def __init__(self, level=1, player=2): 98 | self.level = level 99 | self.player = player 100 | 101 | # --- RANDOM --- 102 | 103 | def rnd(self, board): 104 | empty_sqrs = board.get_empty_sqrs() 105 | idx = random.randrange(0, len(empty_sqrs)) 106 | 107 | return empty_sqrs[idx] # (row, col) 108 | 109 | # --- MINIMAX --- 110 | 111 | def minimax(self, board, maximizing): 112 | 113 | # terminal case 114 | case = board.final_state() 115 | 116 | # player 1 wins 117 | if case == 1: 118 | return 1, None # eval, move 119 | 120 | # player 2 wins 121 | if case == 2: 122 | return -1, None 123 | 124 | # draw 125 | elif board.isfull(): 126 | return 0, None 127 | 128 | if maximizing: 129 | max_eval = -100 130 | best_move = None 131 | empty_sqrs = board.get_empty_sqrs() 132 | 133 | for (row, col) in empty_sqrs: 134 | temp_board = copy.deepcopy(board) 135 | temp_board.mark_sqr(row, col, 1) 136 | eval = self.minimax(temp_board, False)[0] 137 | if eval > max_eval: 138 | max_eval = eval 139 | best_move = (row, col) 140 | 141 | return max_eval, best_move 142 | 143 | elif not maximizing: 144 | min_eval = 100 145 | best_move = None 146 | empty_sqrs = board.get_empty_sqrs() 147 | 148 | for (row, col) in empty_sqrs: 149 | temp_board = copy.deepcopy(board) 150 | temp_board.mark_sqr(row, col, self.player) 151 | eval = self.minimax(temp_board, True)[0] 152 | if eval < min_eval: 153 | min_eval = eval 154 | best_move = (row, col) 155 | 156 | return min_eval, best_move 157 | 158 | # --- MAIN EVAL --- 159 | 160 | def eval(self, main_board): 161 | if self.level == 0: 162 | # random choice 163 | eval = 'random' 164 | move = self.rnd(main_board) 165 | else: 166 | # minimax algo choice 167 | eval, move = self.minimax(main_board, False) 168 | 169 | print(f'AI has chosen to mark the square in pos {move} with an eval of: {eval}') 170 | 171 | return move # row, col 172 | 173 | class Game: 174 | 175 | def __init__(self): 176 | self.board = Board() 177 | self.ai = AI() 178 | self.player = 1 #1-cross #2-circles 179 | self.gamemode = 'ai' # pvp or ai 180 | self.running = True 181 | self.show_lines() 182 | 183 | # --- DRAW METHODS --- 184 | 185 | def show_lines(self): 186 | # bg 187 | screen.fill( BG_COLOR ) 188 | 189 | # vertical 190 | pygame.draw.line(screen, LINE_COLOR, (SQSIZE, 0), (SQSIZE, HEIGHT), LINE_WIDTH) 191 | pygame.draw.line(screen, LINE_COLOR, (WIDTH - SQSIZE, 0), (WIDTH - SQSIZE, HEIGHT), LINE_WIDTH) 192 | 193 | # horizontal 194 | pygame.draw.line(screen, LINE_COLOR, (0, SQSIZE), (WIDTH, SQSIZE), LINE_WIDTH) 195 | pygame.draw.line(screen, LINE_COLOR, (0, HEIGHT - SQSIZE), (WIDTH, HEIGHT - SQSIZE), LINE_WIDTH) 196 | 197 | def draw_fig(self, row, col): 198 | if self.player == 1: 199 | # draw cross 200 | # desc line 201 | start_desc = (col * SQSIZE + OFFSET, row * SQSIZE + OFFSET) 202 | end_desc = (col * SQSIZE + SQSIZE - OFFSET, row * SQSIZE + SQSIZE - OFFSET) 203 | pygame.draw.line(screen, CROSS_COLOR, start_desc, end_desc, CROSS_WIDTH) 204 | # asc line 205 | start_asc = (col * SQSIZE + OFFSET, row * SQSIZE + SQSIZE - OFFSET) 206 | end_asc = (col * SQSIZE + SQSIZE - OFFSET, row * SQSIZE + OFFSET) 207 | pygame.draw.line(screen, CROSS_COLOR, start_asc, end_asc, CROSS_WIDTH) 208 | 209 | elif self.player == 2: 210 | # draw circle 211 | center = (col * SQSIZE + SQSIZE // 2, row * SQSIZE + SQSIZE // 2) 212 | pygame.draw.circle(screen, CIRC_COLOR, center, RADIUS, CIRC_WIDTH) 213 | 214 | # --- OTHER METHODS --- 215 | 216 | def make_move(self, row, col): 217 | self.board.mark_sqr(row, col, self.player) 218 | self.draw_fig(row, col) 219 | self.next_turn() 220 | 221 | def next_turn(self): 222 | self.player = self.player % 2 + 1 223 | 224 | def change_gamemode(self): 225 | self.gamemode = 'ai' if self.gamemode == 'pvp' else 'pvp' 226 | 227 | def isover(self): 228 | return self.board.final_state(show=True) != 0 or self.board.isfull() 229 | 230 | def reset(self): 231 | self.__init__() 232 | 233 | def main(): 234 | 235 | # --- OBJECTS --- 236 | 237 | game = Game() 238 | board = game.board 239 | ai = game.ai 240 | 241 | # --- MAINLOOP --- 242 | 243 | while True: 244 | 245 | # pygame events 246 | for event in pygame.event.get(): 247 | 248 | # quit event 249 | if event.type == pygame.QUIT: 250 | pygame.quit() 251 | sys.exit() 252 | 253 | # keydown event 254 | if event.type == pygame.KEYDOWN: 255 | 256 | # g-gamemode 257 | if event.key == pygame.K_g: 258 | game.change_gamemode() 259 | 260 | # r-restart 261 | if event.key == pygame.K_r: 262 | game.reset() 263 | board = game.board 264 | ai = game.ai 265 | 266 | # 0-random ai 267 | if event.key == pygame.K_0: 268 | ai.level = 0 269 | 270 | # 1-random ai 271 | if event.key == pygame.K_1: 272 | ai.level = 1 273 | 274 | # click event 275 | if event.type == pygame.MOUSEBUTTONDOWN: 276 | pos = event.pos 277 | row = pos[1] // SQSIZE 278 | col = pos[0] // SQSIZE 279 | 280 | # human mark sqr 281 | if board.empty_sqr(row, col) and game.running: 282 | game.make_move(row, col) 283 | 284 | if game.isover(): 285 | game.running = False 286 | 287 | 288 | # AI initial call 289 | if game.gamemode == 'ai' and game.player == ai.player and game.running: 290 | 291 | # update the screen 292 | pygame.display.update() 293 | 294 | # eval 295 | row, col = ai.eval(board) 296 | game.make_move(row, col) 297 | 298 | if game.isover(): 299 | game.running = False 300 | 301 | pygame.display.update() 302 | 303 | main() --------------------------------------------------------------------------------