7
\$\begingroup\$

I'm working on a text-based tic-tac-toe game, mostly for fun and because I'm learning programming. I would appreciate any comments you could give me to improve it.

Main structure:

It uses a Board class for all the games. It's initialized with an empty tic-tac-toe board implemented using numpy arrays. It has views to handle the rows, columns and diagonals directly. It also initializes some other variables (player token, current game turns, etc.).

Most of the the functions are helpers to handle the different moves performed by the computer (completing triples, filling diagonal cases, etc.). The game loop is performed by the play function, which calls the other two main functions: computer_turn and user_turn.

Code:

from itertools import cycle
from random import shuffle
import numpy as np
### TODO:
# - 2 player mode.
# - Choose player/cpu token.
class NoMove(Exception):
 pass
class Board:
 players = cycle('xo')
 def __init__(self):
 self.board = np.array([['', '', ''], ['', '', ''], ['', '', '']])
 self.rows = [self.board[0], self.board[1], self.board[2]]
 self.columns = [self.board[:, 0], self.board[:, 1], self.board[:, 2]]
 # Top to bottom, left to right diagonal
 self.diag0 = np.einsum('ii->i', self.board)
 # To to bottom, right to left diagonal
 self.diag1 = np.einsum('ii->i', np.fliplr(self.board))
 self.diags = [self.diag0, self.diag1]
 self.player = next(Board.players)
 self.user_score = 0
 self.computer_score = 0
 self.turns = 0
 def check_winner(self, player=None):
 """
 player: The token to check
 Checks if a row, column or diagonal is filled with 'player' token.
 If no 'player' token is given, uses curren self.player token.
 Returns True if condition is valid. False otherwise.
 """
 if not player:
 player = self.player
 # Horizontal win
 for i in range(3):
 if np.sum(self.rows[i] == player) == 3:
 print('Horizontal check')
 return True
 # Vertical win
 for j in range(3):
 if np.sum(self.columns[j] == player) == 3:
 print('Vertical check')
 return True
 # Diagonal win
 for d in range(2):
 if np.sum(self.diags[d] == player) == 3:
 print('Diagonal check')
 return True
 return False
 def fill_next(self):
 """
 Fills the next empty case with self.player token. 
 """
 for i in range(3):
 for j in range(3):
 if not self.board[i][j]:
 self.board[i][j] = self.player
 return None
 def fill_corner(self):
 """
 Chooses one corner randomly.
 If it's empty, fills it with self.player token.
 Raises NoMove exception if not possible for all corners.
 """
 random_corners = list(range(4))
 shuffle(random_corners)
 for i in random_corners:
 if i == 0 and not self.board[0][0]:
 self.board[0][0] = self.player
 break
 elif i == 1 and not self.board[0][2]:
 self.board[0][2] = self.player
 break
 elif i == 2 and not self.board[2][0]:
 self.board[2][0] = self.player
 break
 elif i == 3 and not self.board[2][2]:
 self.board[2][2] = self.player
 break
 else:
 raise NoMove
 
 def diagonal_free(self):
 """
 If center case is free, will attempt to fill the diagonally opposed case
 of a case already filled with self.player token.
 Raises a NoMove exception if conditions aren't met.
 """
 if not self.board[1][1]:
 for d in range(2):
 for filled, empty in [(0, 2), (2, 0)]:
 if self.diags[d][filled] == self.player and \
 not self.diags[d][empty]:
 self.diags[d][empty] = self.player
 return None
 else:
 raise NoMove
 else:
 raise NoMove
 def complete_triple(self, target=None):
 """
 target: A player token. If none is given, will use current
 self.player token.
 Scans rows, columns and diagonals for 2 occurrences of target token.
 If 2 cases are filled with 'target', it will fill the remaining case
 with target.
 If no occurrences take place, raises NoMove exception.
 """
 def try_fill(array3):
 """
 array3: An array with 3 elements.
 If two elements of the array are filled with 'target' token and
 one is empty, fills the empty case with current self.player token.
 Returns True if successful. False if not.
 """
 if np.sum(array3 == target) == 2 and \
 np.sum(array3 == '') == 1:
 array3[array3 == ''] = self.player
 return True
 return False
 
 if not target:
 target = self.player
 
 # Rows
 for row in self.rows:
 if try_fill(row):
 return None
 # Columns
 for column in self.columns:
 if try_fill(column):
 return None
 # Diagonals
 for diagonal in self.diags:
 if try_fill(diagonal):
 return None
 raise NoMove
 def win_move(self):
 """
 Searches for rows, columns and diagonals with 2 occurrences of
 self.symbol and completes it to win.
 If no occurrences take place, complete_triple raises NoMove exception.
 """
 self.complete_triple()
 return None
 def avoid_losing(self):
 """
 Searches for rows, columns and diagonals with 2 occurrences of
 the adversary of self.player token. 
 Completes the empty case to avoid losing.
 If no occurrences take place, raises NoMove exception.
 """
 if self.player == 'x':
 target = 'o'
 else:
 target = 'x'
 self.complete_triple(target)
 return None
 def count_corners(self, target=None):
 """
 Returns the number of corners occupied by the current
 self.player token. (0 - 4)
 """
 if not target:
 target = self.player
 return np.sum(self.board[[0, 0, -1, -1], [0, -1, 0, -1]] == target)
 def defend_corner(self):
 """
 Checks if the other player has filled one of the corners of the board.
 Returns True or False.
 """
 if self.player == 'x':
 other_player = 'o'
 else:
 other_player = 'x'
 return self.count_corners(other_player) >= 1
 def scan_one(self, array3):
 """
 array3: An array with 3 elements.
 Scans if token self.token occupies one and only one token
 in array3. All other cases must be empty.
 Returns True if valid. If not, False.
 """
 return np.sum(array3 == self.player) == 1 and \
 np.sum(array3 == '') == 2
 def fill_one(self, array3):
 """
 array3: An array of 3 elements.
 Fills the first empty case in array3 with self.player token.
 Raise NoMove exception if not possible
 """
 for i in range(3):
 if not array3[i]:
 array3[i] = self.player
 return None
 raise NoMove
 def complete_second(self):
 """
 Will scan rows, columns and diagonals (in that order)
 with only one current player case filled and no cases filled
 by the other player. It will then fill one of those cases.
 Returns NoMove exception if no occurrence is possible.
 """
 for i in range(3):
 if self.scan_one(self.rows[i]):
 self.fill_one(self.rows[i])
 return None
 for j in range(3):
 if self.scan_one(self.columns[j]):
 self.fill_one(self.columns[j])
 return None
 for diagonal in self.diags:
 if self.scan_one(diagonal):
 self.fill_one(diagonal)
 return None
 raise NoMove
 def computer_turn(self):
 """
 Attempts different moves.
 If any move succeds, it returns None.
 When a move fails, it raises a NoMove exception and
 attempts the next move.
 """
 self.turns +=1
 if self.player == 'x':
 other_player = 'o'
 else:
 other_player = 'x'
 input('Computer turn... (Press any key to continue)')
 # Starts by filling any of the corners.
 if self.turns <= 2:
 # If the other player started the game and filled
 # any of the corners, fill the center case.
 if self.turns == 2:
 if self.defend_corner():
 self.board[1][1] = self.player
 return None
 try:
 self.fill_corner()
 return None
 except NoMove:
 pass
 # Attempts to win the game with a single move.
 try:
 self.win_move()
 return None
 except NoMove:
 pass
 # If the opponent is about to complete a triple, avoid it.
 try:
 self.avoid_losing()
 return None
 except NoMove:
 pass
 # Attempts filling the diagonally opposed case if
 # the central case is empty.
 try:
 self.diagonal_free()
 return None
 except NoMove:
 pass
 # During mid game, attempts to continue filling corners.
 # First verifies that some corners are already filled by the player
 # or that the opponent hasn't filled the corners already so that
 # it's a worthy move.
 if (self.turns <= 4 and self.count_corners(other_player) != 2) or \
 self.count_corners() >= 2:
 try:
 self.fill_corner()
 return None
 except NoMove:
 pass
 # If no other option, will complete any row/column/diagonal
 # that has one player token and two empty cases.
 try:
 self.complete_second()
 return None
 except NoMove:
 pass
 
 # If no other possible options, fill any empty case.
 self.fill_next()
 return None
 def get_index(self, text):
 """
 Will prompt the player for a row or column index.
 Returns an integer index between 0 - 2.
 """
 index = 0
 while True:
 index = input("Choose a {} (0 - 2)\n".format(text))
 try:
 index = int(index)
 except ValueError:
 print('Please enter a number')
 continue
 if index > 2:
 print('Invalid input. Possible values: 0 - 2')
 else:
 break
 return index
 def try_case(self, i, j):
 """
 Will atempt filling the case[i][j].
 Returns True on success. False otherwise.
 """
 if not self.board[i][j]:
 self.board[i][j] = self.player
 return True
 return False
 def user_turn(self):
 """
 Prompts the user for a row and a column index.
 Verifies case isn't filled.
 """
 self.turns += 1
 print('Your turn')
 while True:
 i = self.get_index('row')
 j = self.get_index('column')
 if self.try_case(i, j):
 break
 else:
 print('Case is filled. Choose another one!')
 return None
 def play(self):
 """
 Game loop.
 Starts with computer turn.
 Alternates turns between player/computer.
 Verifies if there is a winner.
 Verifies if there is a draw.
 Starts new game alternating from last player.
 """
 current_player = self.player
 print('Welcome!')
 self.print_score()
 input('New game?\n(Press any key to continue)')
 print(self)
 while True:
 current_player = self.player
 if current_player == 'x':
 self.computer_turn()
 print(self)
 else:
 self.user_turn()
 print(self)
 if self.check_winner():
 if current_player == 'x':
 self.computer_win()
 else:
 self.player_win()
 self.print_score()
 self.reset()
 print('New game')
 print(self)
 # Loser starts next game
 self.player = next(Board.players)
 elif self.endgame():
 self.print_score()
 print("It's a draw!")
 input('Press any key to continue...')
 self.reset()
 print('New game')
 print(self)
 self.player = next(Board.players)
 else:
 self.player = next(Board.players)
 def endgame(self):
 """
 Checks if 9 turns of the game have elapsed.
 """
 return self.turns == 9
 def reset(self):
 """
 Empties all board cases and sets turns to 0.
 """
 self.board.fill('')
 self.turns = 0
 def computer_win(self):
 """
 Prints computer winning message.
 Increments computer score by one.
 """
 print('Computer won!')
 input('Press enter to continue...')
 self.computer_score += 1
 def player_win(self):
 """
 Prints player winning message.
 Increases player score.
 """
 print('You win!')
 input('Press enter to continue...')
 self.user_score += 1
 def print_score(self):
 """
 Prints score
 """
 print('Current score is:\n Player\t: {}\n CPU\t: {}'
 .format(self.user_score,self.computer_score))
 def __str__(self):
 """
 Game board pretty printing.
 """
 dash = '---'
 counter = 0
 board_str = ''
 for row in self.board:
 board_str += '{: ^5s}|{: ^5s}|{: ^5s}\n'\
 .format(row[0], row[1], row[2])
 if counter < 2:
 counter += 1
 board_str += '{0: ^5s}+{0: ^5s}+{0: ^5s}\n'.format(dash)
 return board_str
board = Board()
board.play()
toolic
14.4k5 gold badges29 silver badges201 bronze badges
asked Aug 15, 2020 at 3:26
\$\endgroup\$
2
  • 1
    \$\begingroup\$ I don't using numpy is really useful for your case. It made it harder for me to understand the code, since I am not familiar with it. Especially "einsum" confused me, made me look up, einsum in numpy, then Einstein notation on wikipedia and I was still confused. I think using plain Python would be more readable here and safe you from an extra dependency. \$\endgroup\$ Commented Aug 15, 2020 at 18:50
  • 1
    \$\begingroup\$ I thought the same at first. Initially, I implemented the board using nested lists. However, whenever I tested or modified the columns, rows or diagonals, I had to explicitly refer to the corresponding elements in the nested list using two indeces, which was cumbersome and prone to error. The advantage of using Numpy arrays is that I can refer to the elements of the board by grouping them in rows, columns and diagonals. The changes on this arrays reflect back on the original board, which offered more expressive functions and clearer code in the long run. Thank you for your comment. \$\endgroup\$ Commented Aug 15, 2020 at 20:21

1 Answer 1

2
\$\begingroup\$

Overview

The code layout is good, you added ample docstrings and you used meaningful names for classes, functions and variables.

When running the code, it handles unexpected input very well. For example, if I enter 7 for a row, it gracefully asks me to enter a valid row, and it continues to do so until I enter a valid row.

End game

There should be an option to cleanly quit after each game. I need to Ctrl-C to quit after a game.

Prompt

The following prompt message is incorrect:

Press any key to continue

Since I really need to press "Enter", this message should be used:

Press enter to continue

Typo

Change "succeds" to "succeeds" in the docstring.

Comments

These "todo" comments should be removed to eliminate clutter:

### TODO:
# - 2 player mode.
# - Choose player/cpu token.
Toby Speight
87.1k14 gold badges104 silver badges322 bronze badges
answered Apr 28, 2024 at 11:02
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.