3
\$\begingroup\$

I have created a two-file tic-tac-toe game, with board.py containing most of the internal workings of the playing board. I intended to have board.py work as general infrastructure for playing the game and testing who won.
game.py is the file to be run if you want to play the game, and it just handles the errors board.py throws, in addition to fetching input from the user and informing about errors and results.
I tried to document it extensively.
One feature I am particularly proud of is the ability to play on e.g. a 7*3 board (sacrificing the chance of winning diagonally). I also tried to hide all errors from the user, the only error that will terminate the program is if they give invalid input in game.py when creating the game board dimensions.

Here is the code:

board.py:

"""
A playing board for tic tac toe. Contains the class Board.
"""
class Board:
    """
    A playing board for tic tac toe.

    Fields:
        rows (int): The number of rows in the board.
        columns (int): The number of columns in the board.
        rowLists (list): A list of lists representing the cells of the board.

    Methods:
        __init__(self, rows: int, columns: int):
            Initializes a new instance of the Board class.

            Parameters:
                rows (int): The number of rows in the board.
                columns (int): The number of columns in the board.

        __str__(self):
            Returns a string representation of the board.

            Returns:
                str: The string representation of the board.

        mark(self, coords: tuple, mark: str):
            Set a mark on the board at the specified coordinates if there is no
            mark there already.

            Parameters:
                coords (tuple): The coordinates of the cell where the mark will
                    be set.
                mark (str): The mark to be set on the cell. Must be either 'X'
                    or 'O'.

            Raises:
                ValueError: If the mark is not 'X' or 'O'.
                ValueError: If the cell is out of range
                ValueError: If the cell has already been marked

            Returns:
                None

        get_rows(self):
            Returns the list of rows in the current object.

            Returns:
                list: A list of rows in the current object.

        check_if_won(self):
            Check if the player has won the game.

            Returns:
                bool|str: The player mark if a player has won, 'Tie' if all
                    cells are marked, and False if no player has won.
    """

    def __init__(self, rows: int, columns: int):
        self.row_lists = [[' ' for _ in range(columns)] for _ in range(rows)]
        self.rows = rows
        self.columns = columns

    def __str__(self):
        result = '   +' + '---+'*self.columns + '\n'
        for i, row in enumerate(self.row_lists):
            result += str(i) + '  |'
            for column in row:
                result += ' ' + column + ' |'
            result += '\n' + '   +' + '---+'*self.columns + '\n'
        result += '   '
        for i in range(self.columns):
            result += '  ' + str(i) + ' '

        return result

    def mark(self, coords: tuple, mark: str):
        """
        Set a mark on the board at the specified coordinates if there is no
        mark there already.

        Parameters:
            coords (tuple): The coordinates of the cell where the mark will be
                set.
            mark (str): The mark to be set on the cell. Must be either 'X' or
                'O'.

        Raises:
            ValueError: If the mark is not 'X' or 'O'.
            ValueError: If the cell is out of range.
            ValueError: If the cell has already been marked.

        Returns:
            None
        """
        if mark not in ('X', 'O'):
            raise ValueError('Invalid mark')
        if coords[0] >= self.rows or coords[1] >= self.columns:
            raise ValueError('Invalid coordinates')
        if self.row_lists[coords[0]][coords[1]] != ' ':
            raise ValueError('Cell already marked')
        row, column = coords
        self.row_lists[row][column] = mark

    def get_rows(self):
        """
        Returns the list of rows in the current object.

        Parameters:
            None

        Returns:
            list: A list of rows in the current object.
        """
        return self.row_lists

    def check_if_won(self):
        """
        Check if the player has won the game.

        Checks if a player has won the game by checking the
        rows, columns, and diagonals of the game board. It iterates through 
        ach row, column, and diagonal and calls the `check_line` function to
        determine if all cells in the line are filled with the same value.

        Parameters:
            None

        Returns:
            bool|str: The player mark if a player has won, 'Tie' if all cells
                are marked, and False if no player has won.
        """
        winner = False

        def check_line(line: list):
            first = line[0]
            for cell in line:
                if cell == ' ' or cell != first:
                    return False
            nonlocal winner
            winner = line[0]
            return True

        def check_diagonals():
            if self.rows != self.columns:
                return False

            diagonals = [[], []]
            for i in range(self.rows):
                diagonals[0].append(self.row_lists[i][i])
                diagonals[1].append(self.row_lists[i][self.columns-1-i])

            return check_line(diagonals[0]) or check_line(diagonals[1])

        def check_rows():
            for row in self.row_lists:
                if check_line(row):
                    return True
            return False

        def check_columns():
            for i in range(self.columns):
                column = [row[i] for row in self.row_lists]
                if check_line(column):
                    return True
            return False

        if check_rows() or check_columns() or check_diagonals():
            return winner

        for row in self.row_lists:
            for cell in row:
                if cell == ' ':
                    return False
        return 'Tie'


if __name__ == '__main__':
    # Testing that everything works as it should
    b = Board(3, 3)
    print(b)
    b.mark((0, 0), 'X')
    b.mark((1, 1), 'X')
    b.mark((2, 2), 'X')
    b.row_lists = [['X', 'Y', 'X'], ['Y', 'X', 'Y'], ['Y', 'X', 'Y']]
    print(b)
    print(b.check_if_won())


game.py

"""
NOT A MODULE
Used to play the tic-tac-toe game
"""
from ast import literal_eval # To not use the built-in eval function
from sys import exit as sys_exit
from board import Board

try:
    rows = int(input('Enter number of rows: '))
    cols = int(input('Enter number of columns: '))
except ValueError:
    print('Invalid input')
    sys_exit() # Because built-in exit() is bad practice

board = Board(rows, cols)

turn = 'X'

while not board.check_if_won():
    print(board)
    print(f'Player {turn}\'s turn')

    try:
        coords = literal_eval(input(
            'Enter coordinates for your mark on the format "row, column": '
        ))
        print('\n'*20)
        board.mark(coords, turn)
    except (ValueError, TypeError) as e:
        # Catches the two exceptions board.mark() can throw (except 'Invalid
        # mark', which cannot happen because of user input).
        print('\n'*20)
        if str(e) == 'Invalid coordinates':
            print('Invalid coordinates!')
            continue
        if str(e) == 'Cell already marked':
            print('Already marked!')
            continue
        print('An unknow error has occured. Please try again.')
        continue

    if turn == 'X':
        turn = 'O'
    else:
        turn = 'X'

winner = board.check_if_won()
if winner in ['X', 'O']:
    print(f'Player {board.check_if_won()} wins!')
else:
    print('It\'s a tie!')

```
\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

Overall the code is organized well and easy to follow. Here are my main points of feedback:

board.py

  1. For the coords: tuple parameter in mark, I would recommend replacing this with row: int, col: int, or you can define something like the following and pass it in as coords: Position. The main reasons for doing this are for increased readability and less of a chance of mixing up row and column.

    from dataclasses import dataclass
    
    @dataclass
    class Position:
        row: int
        col: int
    
  2. Right now mark accepts negative values for the row and column which is probably not intended behavior. For example, because Python's negative indexing is used we either end up in a situation where it "works" but the results could surprise the user (e.g. row: -1, col: -1 will place a mark at row: 2, col: 2 on a 3x3 board) or it just throws a IndexError: list index out of range.

  3. In check_if_won (check_line), you can avoid the use of nonlocal if you instead create an instance variable self.winner under Board, which allows you to mutate self.winner within check_line.

  4. get_rows isn't used anywhere so it can be removed.

game.py

  1. I wouldn't recommend using literal_eval for parsing (row, column) because user input is still untrusted data, and according to that page "Calling it on untrusted data is thus not recommended." Instead we can just parse the row and column from the user input ourselves without too much extra code:

    try:
        text = input('Enter coordinates in the form "row, column": ')
        row, col = tuple(int(n) for n in text.replace(",", "").split()[:2])
    except ValueError:
        print(f"Invalid input `{text}`. Two integers required for the row and column.")
        continue
    
  2. For exception handling, instead of matching on exact error message strings like Invalid coordinates and Cell already marked which will fail silently if the error messages aren't kept in sync across files, it's better to define your own exceptions like InvalidCoordinatesError and CellAlreadyMarkedError, throw them from the Board class and then catch them in game.py.

General/misc comments

  1. Highly recommend Python enumerations as a way of improving code expressiveness and clarity. Example: an enumeration for the marks O, X

    from enum import Enum
    
    class Mark(Enum):
        O = 1
        X = 2
    
        def __str__(self) -> str:
            return self.name
    

    which we could pass into the mark method as mark: Mark in place of mark: str, and then we wouldn't need the extra check of whether mark is a naught or a cross, because the range of all possible values it can take is already defined in the type.

  2. Documentation of the Board class is very thorough but it did confuse me when the documentation of methods like mark and check_if_won was repeated twice. I'd recommend de-duplicating the docs and moving each method docstring directly under each method definition within the class.

  3. Instead of print statements, it's better to have tests written with assertions using one of the Python unit testing frameworks like unittest so you can just run your suite of tests in one go and quickly know if they passed/failed without having to do manual visual inspection of the output.

\$\endgroup\$

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