2
\$\begingroup\$

I made tic-tac-toe (a common practice).

board_and_player.py

class Board:
    def __init__(self):
        self.cells = {"top_l": ' ', "top_m": ' ', "top_r": ' ',
                     "mid_l": ' ', "mid_m": ' ', "mid_r": ' ',
                     "down_l": ' ', "down_m": ' ', "down_r": ' '}

    def generate(self):
        board = self.cells["top_l"] + '|' + self.cells["top_m"] + '|' + self.cells["top_r"] + ' \n'\
        "-----" + ' \n' + \
        self.cells["mid_l"] + '|' + self.cells["mid_m"] + '|' + self.cells["mid_r"] + ' \n' + \
        "-----" + ' \n' + \
        self.cells["down_l"] + '|' + self.cells["down_m"] + '|' + self.cells["down_r"] + ' \n'

        return board


board = Board()


class Player:
    def __init__(self, name, symbol):
        self.name = name
        self.symbol = symbol


    def make_turn(self):

        while True:
            choice_cell_index = input(f"{self.name}'s turn! Pick an empty cell (a number from 1 to 9): ")

            if choice_cell_index.isdigit() and ( int(choice_cell_index) <= 9 and int(choice_cell_index) > 0 )\
            and not choice_cell_index.startswith('0') and board.cells[ list(board.cells.keys())[int(choice_cell_index) - 1] ] == ' ':

                board.cells[ list(board.cells.keys())[int(choice_cell_index) - 1] ] = self.symbol
                break

            else:
                print("\nThis cell is full or your input is wrong.\n")


    def player_won(self):
        # column conditions
        if board.cells["top_l"] == board.cells["mid_l"] == board.cells["down_l"] == self.symbol or\
           board.cells["top_m"] == board.cells["mid_m"] == board.cells["down_m"] == self.symbol or\
           board.cells["top_r"] == board.cells["mid_r"] == board.cells["down_r"] == self.symbol:

           return True

        # row conditions
        elif board.cells["top_l"] == board.cells["top_m"] == board.cells["top_r"] == self.symbol or\
             board.cells["mid_l"] == board.cells["mid_m"] == board.cells["mid_r"] == self.symbol or\
             board.cells["down_l"] == board.cells["down_m"] == board.cells["down_r"] == self.symbol:

             return True

        # diagonal conditions
        elif board.cells["top_l"] == board.cells["mid_m"] == board.cells["down_r"] == self.symbol or\
             board.cells["top_r"] == board.cells["mid_m"] == board.cells["down_l"] == self.symbol:

             return True

        return False

game.py

from board_and_player import *


print("Greetings! Welcome to X's and O's!")


player1 = Player("player 1", 'X')
player2 = Player("player 2", 'O')


while not player1.player_won() or not player2.player_won():
    print('')
    print(board.generate())

    player1.make_turn()

    if player1.player_won() or player2.player_won():
        break

    print('') 
    print(board.generate())

    player2.make_turn()


if player1.player_won():
    print('')
    print(board.generate())

    print("\nPlayer 1 Wins!")


elif player2.player_won():
    print('')
    print(board.generate())

    print("\nPlayer 2 Wins!")
    

else:
    print("\nTie!")

Can anybody show more elegant way to make the board?

And also I'm kinda doubtful of fitting a single logical line into multiple physical lines. I have heart, that it's a bad habit and that I should avoid using it, but....what if it isn't?...That way I didn't make a line too long and it's more readable.

\$\endgroup\$

2 Answers 2

2
\$\begingroup\$

Game Board Data Structure Representation

Using a dictionary for the board's cells is a little awkward, as you probably realized, particularly in this line:

board.cells[ list(board.cells.keys())[int(choice_cell_index) - 1] ] = self.symbol

You really want to use a dictionary only when the data is actually structured in a <Key>: <Value> format. JSON is a great example of this. If you're writing an API and you want your endpoint to reply with a numeric status code and a text description, for example, the result is very naturally expressed using that structure.

{
  "status": 429,
  "message": "Too Many Requests"
}

In the case of the game board, it's more of an encumbrance than a convenience because it makes iteration over the game board pretty awkward, as you saw.

Printing the Board (and Other Objects)

Save for the previous point, I don't really have a problem with the way you printed the board, really. I would imagine that a Python expert would recommend using the __str__ method, which would allow you to simply call print(board), rather than defining a custom method. (You can find all of these methods in the Python data model documentation.)

In the sample below, this is what I used to print the game state every turn. Notice that the Board class' __str__ method does basically the same thing you did in your Board.generate method.

The only real difference is that rather than simply representing the board's cells as strings, each cell is a class in and of itself, with a self.player class that defaults to None if the cell is empty. You can see from the Cell class definition below that the __str__ method does exactly what you would expect: printing the player's symbol (whatever it is), or a blank if it's empty.

class Cell():

    def __init__(self, player: Player = None):
        self.player = player
    
    def __str__(self):
        return f"{self.player}" if self.player is not None else " "

The Player class is just as simple, basically, with the only real difference being the constructor taking the player's name and desired symbol, just as you had in your version.

class Player():
    
    def __init__(self, name: str, symbol: str):
        self.name = name
        self.symbol = symbol
    
    def __str__(self):
        return f"{self.symbol}"

Ultimately, the difference is really only superficial, honestly. Again, besides what I said above about using a list over a dictionary, I don't have a problem with the way you printed the game board.

Structure

If I'm not mistaken, it looks like in the event of a tie, your game would loop forever, since you're main game loop is defined like this:

while not player1.player_won() or not player2.player_won():
    ...

Even though you're checking for a tie below the main loop, it doesn't look like you can actually get there from the game loop itself.

I added a check to see how many empty cells there were left in my version below, although I don't actually think this is a complete solution. I believe it's possible for there to be empty cells left without there being winning moves left. You can at least keep filling in the empty cells, though, and the game will recognize the tie (eventually).

Also, while I like that you actually defined your turn process and winning condition check as their own functions (I didn't do that, but because I was being lazy, not because it's a good idea), I don't think attaching them to the player is a good idea. At least for the purpose of a Tic Tac Toe implementation, I think the player objects should contain only the necessary state (probably only the name and symbol of its player), and everything else should be handled by the game. I used a Game class for this, but you don't have to, especially if you're just going to quit at the end, which I did.

In a bigger game, you tend to have a main Game class that manages scenes and state, and that gives you the ability to do things like add an options screen, restart, etc., all within the game itself, and especially if you wanted to have different game modes like "Campaign", "Multiplayer", etc.

I added a GameState enum to keep track of the game state of course, but the reason I added a win state for each player rather than simply printing "Player 1 Wins," for example, is that keeping track of specifically which player won allows you to then do things like update the Elo ratings of each player, if you were making an online multiplayer game, say, since such a calculation obviously requires that you know specifically which player won.

Reading Input

When you get the user's selection, you actually call isdigit to make sure you got an actual number, which is great. This isn't optimal, though, because the input is a string, and keeping it as a string leads you to either basically parse the input yourself, or potentially misinterpret it. In your version, you check that the number doesn't start with a zero, for example.

I recommend simply converting the input to an integer, and dealing with the potential ValueError exception if it's not valid instead, for two reasons. One, a numeric string that starts with a zero is correctly interpreted (i.e., "09" is correctly interpreted as 9, and is thus valid), and two, you can then simply compare whether the input is simply between 1 and 9, inclusive.

Parsing and interpreting input strings is complicated; if you're going to do it, do it on purpose. Otherwise, save yourself the headache and deal with data in its native format.

# Get the current player's selection for
# their next move. We wrap this in a
# try/catch block because if the user inputs
# anything other than an integer, the int
# constructor will throw a ValueError
# exception.
try:
    player_selection = int(input(f"{self.current_player.name}, please enter a number from 1 to 9: "))
except ValueError as e:
    # Notify the user of their error and
    # loop again.
    print("Please enter only an integer from 1 to 9.")

    # Try again.
    continue

# Rest of input processing in the sample below.
# ...

Final Thoughts

With regards to your question about long lines and whether to split them, I have two recommendations, one specific and one general. As it specifically relates to Python, I would recommend you follow the PEP 8 Guidelines, since they specifically define a standard Python code style. Generally speaking, I say focus on writing clean, effective, readable code that you (and whomever you program or work with) can read. What that ends up meaning specifically (i.e.., the specific allowable length of a line of code, etc.) is up to you.

You should keep in mind, however, that the number of people willing to read your code is inversely proportional to how far you stray from established conventions.

Complete Sample

from enum import Enum, auto, unique


class Player():
    
    def __init__(self, name: str, symbol: str):
        self.name = name
        self.symbol = symbol
    
    def __str__(self):
        return f"{self.symbol}"

class Cell():

    def __init__(self, player: Player = None):
        self.player = player
    
    def __str__(self):
        return f"{self.player}" if self.player is not None else " "

class Board():

    def __init__(self):
        self.cells = [ Cell() for i in range(9) ]

    def __str__(self):
        s = ""
        divider = "-------\n"
        s += divider
        for i in range(3):
            s += "|"
            for j in range(3):
                s += str(self.cells[i*3+j]) + "|"
            s += "\n" + divider
        return s

@unique
class GameState(Enum):
    IN_PROGRESS = auto()
    PLAYER_ONE_WINS = auto()
    PLAYER_TWO_WINS = auto()
    TIE = auto()

class Game():

    def __init__(self):
        # Initialize the game's players.
        self.players = [ Player('Player One', 'X'), Player('Player Two', 'O') ]

        # Initialize the game board.
        self.board = Board()

        # Initialize the game state.
        self.state = GameState.IN_PROGRESS

        # Set the current player.
        self.current_player = self.players[0]

    def start(self):

        while self.state == GameState.IN_PROGRESS:

            # Display the current state of the board.
            print(self.board)
            
            # This loop handles the process of getting,
            # validating, and processing the current
            # player's selection for their next move.
            while True:
                # Get the current player's selection for
                # their next move. We wrap this in a
                # try/catch block because if the user inputs
                # anything other than an integer, the int
                # constructor will throw a ValueError
                # exception.
                try:
                    player_selection = int(input(f"{self.current_player.name}, please enter a number from 1 to 9: "))
                except ValueError as e:
                    # Notify the user of their error and
                    # loop again.
                    print("Please enter only an integer from 1 to 9.")

                    # Try again.
                    continue

                # Make sure the player's input is a number
                # from 1 to 9.
                if player_selection < 1 or player_selection > 9:
                    # Notify them of their mistake and loop.
                    print("Your selection must be a number from 1 to 9.")

                    # Try again.
                    continue

                # Make sure the selected cell actually
                # represents a legal move (i.e., the cell is
                # empty).
                if self.board.cells[player_selection-1].player is not None:
                    # Notify the player of their mistake and
                    # loop.
                    print("The selected cell is not empty.")

                    # Try again.
                    continue

                # Mark the player's choice on the board.
                self.board.cells[player_selection-1].player = self.current_player

                # If the player's choice was successfully
                # processed, go ahead and break out of the
                # loop.
                break

            # Check whether the game state has changed,
            # either because someone won or because there
            # are no more legal moves left. We begin by
            # checking rows for a win.
            for i in range(3):
                if self.board.cells[i].player == self.board.cells[i+1].player == self.board.cells[i+2].player == self.current_player:
                    self.state = GameState.PLAYER_ONE_WINS if self.current_player == self.players[0] else GameState.PLAYER_TWO_WINS
            
            # Check whether the game state changed so we can
            # skip the rest of the checks. We couldn't use a
            # break in the check because it would have only
            # broken us out of the for loop.
            if self.state != GameState.IN_PROGRESS:
                break
            
            # Check columns for a win.
            for i in range(3):
                if self.board.cells[i].player == self.board.cells[i+3].player == self.board.cells[i+6].player == self.current_player:
                    self.state = GameState.PLAYER_ONE_WINS if self.current_player == self.players[0] else GameState.PLAYER_TWO_WINS

            # Again, break out of the loop to skip the rest
            # of the checks if the game state changed.
            if self.state != GameState.IN_PROGRESS:
                break

            # Check the diagonals for a win.
            if self.board.cells[0].player == self.board.cells[4].player == self.board.cells[8].player == self.current_player:
                self.state = GameState.PLAYER_ONE_WINS if self.current_player == self.players[0] else GameState.PLAYER_TWO_WINS
                break
            elif self.board.cells[2].player == self.board.cells[4].player == self.board.cells[6].player == self.current_player:
                self.state = GameState.PLAYER_ONE_WINS if self.current_player == self.players[0] else GameState.PLAYER_TWO_WINS
                break
            
            # Check for a tie by counting the empty cells
            # left.
            empty_cells = 0

            for i in range(3):
                for j in range(3):
                    if self.board.cells[i*3+j].player is None:
                        empty_cells += 1
            
            # If there are no more empty cells, and no one
            # has won yet, we've ended up with a tie.
            if empty_cells == 0:
                self.state = GameState.TIE
                break

            # If the game isn't over yet, move on to the
            # next player's turn.
            if self.state == GameState.IN_PROGRESS:
                self.current_player = self.players[0] if self.current_player is self.players[1] else self.players[1]

        # Display the final state of the board.
        print(self.board)

        # Handle a tie. You probably want a better message
        # here, but I had nothin'.
        if self.state == GameState.TIE:
            print("Tie. Game Over.")
            return
        
        # Handle the case where a player actually won.
        print(f"{self.current_player.name} wins!")


# Initialize and start the game only if the application is
# being run as a script. Otherwise, we simply import the
# class definitions.
if __name__ == '__main__':
    # Initialize the game.
    game = Game()

    # Start the game.
    game.start()
\$\endgroup\$
2
\$\begingroup\$

list vs dict

I think this is a good example of when to use a list instead of dict. If you're in Python 3.6+, you could rely on the dictionary being ordered, but I think it makes a bit more sense as a list, since I'm not really going to rely on key access all the time except for maybe choosing a spot on the board for my move.

If the board is simply a list of empty strings:

board = [['' for _ in range(3)] for _ in range(3)]

It's easy to format the board and check for win conditions. Let's start with displaying the board:

def display_board(board):
    print('\n---\n'.join(('|'.join(row) for row in board)))

Now, I can simply rely on the order and join each cell in a row with a pipe character '|', and join each row with the newline-separated dashed line.

The real benefit is for win-checking. You can simplify it down into three separate cases: diagonals, rows, and columns

Check Diagonal Wins

To check the diagonal win, we are enumerating the rows and checking the column by using the index. This allows us to very easily check the down-and-right diagonal:

marker = 'x'
board = [
    ['x', '', ''],
    ['', 'x', ''],
    ['', '', 'x']
]

all(row[i] == marker for i, row in enumerate(board))
True

And to check the up-and-left diagonal, we only need to reverse the order of the rows in the board:

board = [
    ['', '', 'x'],
    ['', 'x', ''],
    ['x', '', '']
]

all(row[i] == marker for i, row in enumerate(reversed(board)))
True

For vertical wins, we need only fix the index in place as we check each column:

all(row[0] == marker for row in board) # check first column
all(row[1] == marker for row in board) # and so on

# we can condense this down to an any statment
any(all(row[i] == marker for row in board) for i in range(3))

And horizontal wins are the easiest of them all:

any(all(cell == marker for cell in row) for row in board)

Putting this all together:

def check_win(board, marker):
    # Check horizontal
    if any(all(cell == marker for cell in row) for row in board):
        return True
    # Check vertical
    elif any(all(row[i] == marker for row in board) for i in range(3)):
        return True
    # check diagonals
    elif all(row[i] == marker for i, row in enumerate(board)):
        return True
    elif all(row[i] == marker for i, row in enumerate(reversed(board))):
        return True
    else:
        return False

User Choice

I think displaying the board as a set of available choices might make it a bit easier for the user to pick where to move next. I like your idea of choosing 0-9, so let's build on the display_board function to get a set of available moves:

def get_available_moves(board):
    # Since we want moves 0-9, we can use a for loop with enumerate
    # to adjust the numbers that get displayed
    k = 0
    moves = {}
    printable_board = []

    for i, row in enumerate(board):
        to_print = []
        for j, cell in enumerate(row, start):
            if not cell:
                # The if not cell checks if a cell is taken already
                to_print.append(f'{k}')
                moves[k] = (i, j)
            else:
                to_print.append(cell)
            k += 1
        printable_board.append(to_print)

    display_board(printable_board)
    return moves


# Now, you can display the available moves and have the user
# choose directly
board = [
    ['x', 'o', ''],
    ['o', '', 'x'],
    ['o', 'x', '']
]

available = get_available_moves(board)

# prints this
x|o|2
---
o|4|x
---
o|x|8

# It's easy to check if there are any moves left
if not available:
    print("No moves left! Game over!")

# And also relatively easy to go into a loop to
# check user input
while True:
    try:
        choice = int(
            input("Choose a move from {', '.join(available)}: ")
        )
        # this will raise a KeyError if an invalid choice is selected
        move = available[choice]
    except:
        print("Invalid choice, try again")
    else:
        break

Last, to actually make the move

row, col = move
board[row][col] = player.marker

Switching from player to player

This is easily accomplished by using itertools.cycle while playing the game:

from itertools import cycle

def main():
    board = [['' for _ in range(3)] for _ in range(3)]

    players = cycle(
       [Player(name='player1', marker='x'), 
        Player(name='player2', marker='o')]
    )
    
    # this is an infinite loop
    for player in players:
        print(f"{player.name}'s turn")
        
        available = get_available_moves(board)
        
        if not available:
            print("No moves left, draw!")
            break

        row, col = choose_move(available)
        board[row][col] = player.marker

        display_board(board)

        if board_has_win(board=board, marker=player.marker):
            print(f"{player.name} wins!")
            break

Wrapping Board into a Class

You made the right decision by wrapping Board into a class, it gives you a few benefits to do so. First, display_board becomes the __str__ dunder method. Next, we can use a few other dunder methods to clean up attribute access:

class Board:
    def __init__(self):
        self.board = [[' ' for _ in range(3)] for _ in range(3)]


    def __str__(self):
        return '\n---\n'.join(('|'.join(row) for row in self))


    def __getitem__(self, attr):
        # allows us to index Board instances
        return self.board[attr]


    def __len__(self):
        return len(self.board)

And since we've defined __len__ and __getitem__, Board is now iterable:

board = Board()

for row in board:
    print(row)

Then, we can put most of the functions that use board into the class. check_win becomes has_win, since that naming implies that it will return a boolean. We can also include checking for available moves, which cleans up the functions a decent bit:

class Board:
    ~snip~
    def get_available_moves(self):
        k = 0
        moves = {}
        # make a copy of the board so that we
        # don't overwrite the actual game board
        printable_board = Board()

        for i, row in enumerate(self):
            for j, cell in enumerate(row, start):
                # check if cell is occupied
                if not cell.strip():
                    printable_board[i][j] = f"{k}"
                    moves[k] = (i, j)
                else:
                    printable_board[i][j] = cell

                k += 1

        print(printable_board)
        return moves


    def has_win(self, marker):
        """
        Check if there is a win present on the board, 
        return True if so, otherwise False
        """
        # Check horizontal
        if any(all(cell == marker for cell in row) for row in self):
            return True
        # Check vertical
        elif any(all(row[i] == marker for row in self) for i in range(3)):
            return True
        # check diagonals
        elif all(row[i] == marker for i, row in enumerate(self)):
            return True
        elif all(row[i] == marker for i, row in enumerate(reversed(self))):
            return True
        else:
            return False 

Player class

I think that you could use namedtuple for Player, since you are just holding the name and marker for each player:

from collections import namedtuple

Player = namedtuple('Player', ['name', 'marker'])

player1 = Player(name="John", marker='x')
player2 = Player(name="Anne", marker='o')

The state of each player isn't mutable, so I don't need setattr access on any of the attributes. It gives me a clean dot access to the marker attribute, and it prints nicely, too.

print(player1)
Player(name='John', marker='x')

player1.marker
x

All together

from collections import namedtuple
from itertools import cycle


Player = namedtuple('Player', ['name', 'marker'])

class Board:
    def __init__(self):
        self.board = [[' ' for _ in range(3)] for _ in range(3)]


    def __str__(self):
        return '\n-----\n'.join(('|'.join(row) for row in self))


    def __getitem__(self, attr):
        return self.board[attr]


    def __len__(self):
        return len(self.board)


    def get_available_moves(self):
        """Returns a dictionary of available moves
        and prints an enumerated board"""
        k = 0
        moves = {}
        # make a copy of the board so that we
        # don't overwrite the actual game board
        printable_board = Board()

        for i, row in enumerate(self):
            for j, cell in enumerate(row):
                # check if cell is occupied
                if not cell.strip():
                    printable_board[i][j] = f"{k}"
                    moves[k] = (i, j)
                else:
                    printable_board[i][j] = cell
                k += 1

        print(printable_board)
        return moves


    def has_win(self, marker):
        """
        Check if there is a win present on the board, 
        return True if so, otherwise False
        """
        # Check horizontal
        if any(all(cell == marker for cell in row) for row in self):
            return True
        # Check vertical
        elif any(all(row[i] == marker for row in self) for i in range(3)):
            return True
        # check diagonals
        elif all(row[i] == marker for i, row in enumerate(self)):
            return True
        elif all(row[i] == marker for i, row in enumerate(reversed(self))):
            return True
        else:
            return False       


def choose_move(moves):
    """Prompt player to choose a move from a set of available spots"""
    while True:
        try:
            choice = int(
                input(f"Choose a move from {', '.join(map(str, moves))}: ")
            )
            # this will raise a KeyError if an invalid choice is selected
            move = moves[choice]
        except:
            print("Invalid choice, try again")
        else:
            break
    return move
        


def main():
    board = Board()

    players_iter = cycle(
       [Player(name='player1', marker='x'), 
        Player(name='player2', marker='o')]
    )

    # this is an infinite loop
    for player in players_iter:
        print(f"{player.name}'s turn")

        available = board.get_available_moves()

        # check if there are any spots left
        if not available:
            print("No moves left, draw!")
            break

        row, col = choose_move(available)
        board[row][col] = player.marker

        print(board)

        if board.has_win(marker=player.marker):
            print(f"{player.name} wins!")
            break


if __name__ == "__main__":
    # play the game
    main()
        
\$\endgroup\$

Your Answer

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

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