9
\$\begingroup\$

I am looking for criticism or direction for simplifying this game. I plan to build a player class and Parent that into a terminal player and a my first machine learning minimax algorithm.

For that reason, I am looking for a really concise direction on how I could better implement the basic function of a tic-tac-toe board and methods.

Enjoy!


class Board:

    def __init__(self):
        self.moves = {1:" 1 ", 2:" 2 ", 3:" 3 ", 4:" 4 ", 5:" 5 ", 6:" 6 ", 7:" 7 ", 8:" 8 ", 9:" 9 "}
        #self.board = self.build_board()
        self.player_turn = None
        self.game_play_on = True
        #temporarily instituting a move count in place of checking if possible moves can be made
        self.total_moves = 0

    def make_move(self, player):

        while True:
            try:
                move = int(input("Make Move:  "))
                if move in self.moves:
                    if self.moves[move] not in [" X ", " O "]:
                        self.moves[move] = f" {player} "
                        print("LEGAL MOVE!")
                        self.total_moves += 1
                        break
            except (TypeError, ValueError) as error:
                print(error)
            print("Please Make a Legal Move")

    def build_board(self):
        return f" {self.moves[1]}| {self.moves[2]}| {self.moves[3]}\n____|____|____\n {self.moves[4]}| {self.moves[5]}| {self.moves[6]}\n____|____|____\n {self.moves[7]}| {self.moves[8]}| {self.moves[9]}\n    |    |    "

    def print_board(self):
        print(self.build_board())

    def player(self, state):
        if (state.count("X") + state.count("O")) % 2 == 0:
            self.player_turn = "X"
        else:
            self.player_turn = "O"

    def terminal(self, state):
        #takes a state and returns if the game is over
        possible_wins = [(1,2,3), (4,5,6), (7,8,9), (1,4,7), (2,5,8), (3,6,9), (1,5,9), (7,5,3)]
        moves = []
        for value in self.moves:
            if self.moves[value] == f" {self.player_turn} ":
                moves.append(value)
        for win in possible_wins:
            count = -len(win)
            for number in win:
                if number in moves:
                    count += 1
                if count >= 0:
                    print(f"{self.player_turn.strip()} WINS THE GAME!")
                    self.game_play_on = False
        #this line is going to be replaced by if actions(s) returns None
        if self.total_moves >= 9:
            print("TIE! Better luck next time!")
            self.game_play_on = False

#Simple in file execution (This is going to be built into further classes in a ML version)
newboard = Board()
newboard.print_board()
while newboard.game_play_on:
    newboard.player(newboard.build_board())
    newboard.make_move(newboard.player_turn)
    newboard.print_board()
    newboard.terminal(newboard.build_board())
\$\endgroup\$
5
  • \$\begingroup\$ Im super hurt that I was the first to notice that I should have listed the possible wins as a class variable instead of creating it within the instance, because the possible wins are standard across every game of tic-tac-toe. \$\endgroup\$ Oct 19, 2022 at 1:12
  • \$\begingroup\$ What do you mean by "a my first"? "is my first"? Or something else? \$\endgroup\$ Oct 19, 2022 at 20:24
  • \$\begingroup\$ Please do not edit the question, especially the code, after an answer has been posted. Changing the question may cause answer invalidation. Everyone needs to be able to see what the reviewer was referring to. What to do after the question has been answered. \$\endgroup\$
    – pacmaninbw
    Oct 19, 2022 at 20:38
  • \$\begingroup\$ Nothing was changed after any comment was made by any new party. The code was changed before my comment and then my comment was made. I understand how any changing of the code would be reason for invalidation and will refrain from making such a blunder in the future \$\endgroup\$ Oct 19, 2022 at 21:47
  • \$\begingroup\$ @PeterMortensen It was supposed to be ML for Machine learning, but a spell correct must have changed it before submitting. Thank you for pointing that out. \$\endgroup\$ Oct 19, 2022 at 21:48

1 Answer 1

10
\$\begingroup\$

Your Board class is handling too much at present. It's managing the following items:

  • The board itself
  • Whose move it is
  • Making moves for either player
  • Printing the board
  • Playing the game
  • Displaying game status

Let's boil it down to the Board being just a board. It carries the state of what moves have been put where, which really can just be shown on the board itself. To combine showing what moves have been put where, let's initialize your dictionary to contain Nones for values, that way it's really easy to check what has been taken:

class Board:
    def __init__(self):
        # a dictionary comprehension here makes this much more compact
        self.board = {i: None for i in range(1, 10)}

Printing

Next, your print_board() really could just be the __str__ dunder method, allowing you to print(board). This gets a little funky because of the dictionary:

class Board:
    ~snip~
    
    def __str__(self):
        rows = []
        it = iter((v or str(k) for k, v in self.board.items()))

        while True:
            try:
                rows.append('|'.join((next(it) for _ in range(3))))
            except:
                break

        return '\n'.join(rows)

A little explanation. Because we've set v to None by default, you can use or to either use the value set on the board if a player has chosen that spot, or the string value of the key, which is what you were using before.

None or '1'
'1'

'X' or '1'
'X'

Next, I am using an iterator to break up the keys into sets of 3:

d = {k: None for k in range(1, 10)}

it = iter((v or str(k) for k, v in d.items())
print([next(it) for _ in range(3)]
['1', '2', '3']

Then joining them together with a pipe character:

'|'.join(row)
'1|2|3'

And last, newline-joining the rows.

Now instead of using board.print_board() you can just print(board)

Checking for Player Turn

This shouldn't be part of the Board class. You can implement this as a for loop using itertools.cycle over the player pieces:

from itertools import cycle

for move in cycle('XO'):
    print(move)


X
O
X
O
...

Ideally, this would be wrapped in either a Game class or in a main function that actually plays the game:

def main():
    board = Board()

    # You haven't implemented this, but let's pretend you have
    you = Player(name='player1', marker='X')
    cpu = Player(name='player2', marker='Y', is_cpu=True) 

    # you can just cycle the players themselves
    for player in cycle((you, cpu)):
        # play game here
        ...

Making Moves

The board can check for constraints on a chosen move, I think using the __setitem__ and __getitem__ methods could do nicely here:

class Board:
    ~snip~

    def __getitem__(self, move):
        try:
            return self.board[move]
        except KeyError as e:
            spots = ', '.join(self.board)
            raise ValueError(
                "Didn't find that move on the board! "
                f"Expected one of {spots}"
            )

    def __setitem__(self, move, marker):
        check = self.board.get(move, 'Invalid')
 
        if check is not None:
            remaining = ', '.join((k for k, v in self.board.items() if not v))
            raise ValueError(
                f"Got {check}. Either move is not valid or spot is taken. "
                f"Expected one of {remaining}"
            )
        
        self.board[move] = marker

This way, you can very easily just set the marker of your choice on the board directly:

board = Board()

board[1] = 'X'

board[1] = 'O'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 6, in __setitem__
ValueError: Got 1. Either move is not valid or spot is taken. 
Expected one of 2, 3, 4, 5, 6, 7, 8, 9

# it also catches bad values
board['thing'] = 'X'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 6, in __setitem__
ValueError: Got 'thing'. Either move is not valid or spot is taken. 
Expected one of 2, 3, 4, 5, 6, 7, 8, 9

Now, you can very easily take a turn in a function not related to the board at all. Going back to the cycle idea:

# Not a perfect function but it gets the point across
def player_move(marker, board):
    while True:
        choice = int(input("Which move would you like from {board}? "))
        try:
            board[choice] = marker
        except:
            print('try again')
        else:
            break
    return board

for marker in cycle('XO'):
    if marker == 'O':
       choice = random.choice([k for k, v in board.board if v is None])
       board[choice] = marker
       continue

    board = player_move(marker)

Playing the game

Since you haven't implemented a Player class, we can easily simulate one using a collections.namedtuple:

from collections import namedtuple


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

player1 = Player('player1', 'X')
player2 = Player('player2', 'O')

# Which can be easily extended to looping:
for name, marker in cycle((player1, player2)):
    # play game

Now, to play the game, we can make a quick function to represent the player choice:

def player_move(marker, board):
    while True:
        choice = int(input(f"Choose a move from the board: {board} "))
        
        try:
            board[choice] = marker
        # remember the errors in __getitem__ and __setitem__?
        # you can now catch these as your "wrong choice" 
        except ValueError as e:
            print(f"Invalid choice: {e}")
            print("Try again")
        else:
            break 
    return board 

Now, the main loop just looks like:


def main():
    player1 = Player('player1', 'X')
    player2 = Player('player2', 'O')

    for name, marker in cycle((player1, player2)):
        # might as well say whose turn it is
        print(f"It's {name}'s turn! You are marker {marker}")
        board = player_turn(marker, board)
   
       # check for a win condition of some sort here
       if is_win:
           print(f"{name} won!")
           break

Checking Possible Wins

Fortunately, you've enumerated all of the win conditions. Let's make that a class variable on the board, since it's invariant:

class Board:
    possible_wins = [(1,2,3), (4,5,6), (7,8,9), (1,4,7), (2,5,8), (3,6,9), (1,5,9), (7,5,3)]

However, you loop through all of the conditions and sum to see if any conditions have been met. Use your same loop and just short-circuit when you find a triple that has all three elements the same value:

class Board:
    ~snip~

    def is_win(self, marker):
        # check rows
        for coords in self.possible_wins:
           # all three values must be the same value
           if all(self[coord] == marker for coord in coords):
               return True
        return False

Checking for a stalemate

It's easy to check for a stalemate, all of the values need to be non-null:

class Board:
    ~snip~

    def is_stalemate(self):
        # any None values will cause this to return False
        return all(self.board.values())

And in your game:

def main():
    ~snip~
        if board.is_win():
            ...
        elif board.is_stalemate():
            print("Stalemate! No winners this time!")
            break

Putting it all together

Your board class now looks like:

from itertools import cycle
from collections import namedtuple


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


class Board:

    possible_wins = [
        (1,2,3), 
        (4,5,6), 
        (7,8,9), 
        (1,4,7), 
        (2,5,8), 
        (3,6,9), 
        (1,5,9), 
        (7,5,3)
    ]

    def __init__(self):
        # a dictionary comprehension here makes this much more compact
        self.board = {i: None for i in range(1, 10)}

    def __str__(self):
        rows = []
        it = iter((v or str(k) for k, v in self.board.items()))

        while True:
            try:
                rows.append('|'.join((next(it) for _ in range(3))))
            except:
                break

        return '\n'.join(rows)


    def __getitem__(self, move):
        try:
            return self.board[move]
        except KeyError as e:
            spots = ', '.join(self.board)
            raise ValueError(
                "Didn't find that move on the board! "
                f"Expected one of {spots}"
            )

    def __setitem__(self, move, marker):
        check = self.board.get(move, 'Invalid')

        if check is not None:
            remaining = ', '.join((k for k, v in self.board.items() if not v))
            raise ValueError(
                f"Got {check}. Either move is not valid or spot is taken. "
                f"Expected one of {remaining}"
            )

        self.board[move] = marker


    def is_win(self, marker):
        # check rows
        for coords in self.possible_wins:
           # all three values must be the same value
           if all(self[coord] == marker for coord in coords):
               return True
        return False


    def is_stalemate(self):
        return all(self.board.values())

And playing the game now looks like:

def player_move(marker, board):
    while True:
        choice = int(input(f"Choose a move from the board: {board} "))
        
        try:
            board[choice] = marker
        except ValueError as e:
            print(f"Invalid choice: {e}")
            print("Try again")
        else:
            break 
    return board           
    

def main():
    board = Board()
    player1 = Player('player1', 'X')
    player2 = Player('player2', 'O')

    for name, marker in cycle((player1, player2)):
        print(f"{name}'s turn! You are {marker}")
        board = player_move(board=board, marker=marker)
   
        if board.is_win(marker):
            print(f"{name} wins!")
            break
        elif board.is_stalemate():
            print('No winners, stalemate!')
            break
            
\$\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.