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