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()

\$\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\$
    – Helena
    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.
\$\endgroup\$

Not the answer you're looking for? Browse other questions tagged or ask your own question.