Playing Tic-Tac-Toe#

Hello, everyone! This week, I held a seminar where I live-coded the game of tic-tac-toe based on some constraints from a client. I wanted to share with you what the final version of this code would look like after a round of review.

Before we get started, I want to tell you about my upcoming seminar with a similar theme, “A Python Expert’s Approach to Rock, Paper, Scissors.” During this seminar, we’ll dissect the game’s rules, design custom Python functions, and explore the strategic thinking behind this simple yet captivating game. We’ll start with the basics, modeling the game using core Python data structures, and then quickly progress to incorporate more advanced features.

Alright, back to tic-tac-toe.

Requirements#

First, let’s look at the requirements we’re working from:

  • Standard 3x3 game of tic-tac-toe

  • Given a board, determine if a player won and who that player is.

First Pass#

def transpose(board):
    yield from zip(*board)

def winner(row):
    uniq_row = set(row) - {'.'}
    if len(uniq_row) == 1:
        return True, next(iter(uniq_row))
    return False, '.'

def rows(board):
    yield from board

def columns(board):
    yield from transpose(board)

def ldiag(board):
    for i, row in enumerate(rows(board)):
        yield row[i]

def rdiag(board):
    for i, row in enumerate(rows(board), start=1):
        yield row[-i]

The above functions are designed to be fairly composable, allowing me to navigate a board of tic-tac-toe any way I would like. For example…

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

print(
    'Rows', *rows(board),
    'Columns', *columns(board),
    'left diagonal', [*ldiag(board)],
    'right diagonal', [*rdiag(board)],
    sep='\n',
)
Rows
['o', 'o', 'o']
['x', 'x', 'x']
['x', 'o', 'o']
Columns
('o', 'x', 'x')
('o', 'x', 'o')
('o', 'x', 'o')
left diagonal
['o', 'x', 'o']
right diagonal
['o', 'x', 'x']

While each of the strings here are correct, we can see that these functions do not agree with one another on a uniform output.

  1. rows returns lists

  2. columns returns tuples

  3. ldiag and rdiag yield each element instead of collections

These small disagreements led to a very clunky rendition of detecting whether or not a player won the game.

def game_winner(board):
    # check if there is a win in the row
    for i, row in enumerate(rows(board)):
        won, victor = winner(row)
        if won:
            return won, victor

    # check if there is a win in the column
    for i, col in enumerate(columns(board)):
        won, victor = winner(col)
        if won:
            return won, victor

    # check if there is a win in the left diagonal
    won, victor = winner(ldiag(board))
    if won:
        return won, victor

    # check if there is a win in the right diagonal
    won, victor = winner(rdiag(board))
    if won:
        return won, victor
    return False, '.'

game_winner(board) # where did the win occur? a row, column, diagonal?
(True, 'o')

Review#

This game_winner function has WAY too many return statements. This makes the code harder to test (many different exit points) and understand. Additionally it means we need to constantly check our returned values via if statements whenever we want to do something with them.

If we improve the homogeneity of the return types from our original board’s traversal functions, we should also be able to GREATLY simplify the logic in this game_winner function. To tidy up, I am going to have each function generate tuples as it traverses.

Second Pass#

def transpose(board):
    yield from zip(*board)

def winner(row):
    uniq_row = set(row) - {'.'}
    if len(uniq_row) == 1:
        return True, next(iter(uniq_row))
    return False, '.'

def rows(board):
    yield from (tuple(row) for row in board)

def columns(board):
    yield from transpose(board)

def ldiag(board):
    diag = []
    for i, row in enumerate(rows(board), start=0):
        diag.append(row[i])
    yield tuple(diag)

def rdiag(board):
    diag = []
    for i, row in enumerate(rows(board), start=1):
        diag.append(row[-i])
    yield tuple(diag)

Now to traverse my board, I can use each of these functions in the exact same manner. Note that I call each of them using unpacking syntax here:

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

print(
    'Rows', *rows(board),
    'Columns', *columns(board),
    'left diagonal', *ldiag(board),
    'right diagonal', *rdiag(board),
    sep='\n',
)
Rows
('o', 'o', 'o')
('x', 'x', 'x')
('x', 'o', 'o')
Columns
('o', 'x', 'x')
('o', 'x', 'o')
('o', 'x', 'o')
left diagonal
('o', 'x', 'o')
right diagonal
('o', 'x', 'x')

This means I can treat the outputs for any of these functions as the same, so I can simplify my logic! Instead of manually calling each function, I can iterate over them and call them in a uniform way.

def traverse(board):
    for trav in [rows, columns, rdiag, ldiag]:
        for i, group in enumerate(trav(board)):
            yield trav, i, group


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

print(
    *(f'{trav.__name__: <8} {i}: {group}'
    for trav, i, group in traverse(board)),
    sep='\n',
)
rows     0: ('o', 'o', 'o')
rows     1: ('x', 'x', 'x')
rows     2: ('x', 'o', 'o')
columns  0: ('o', 'x', 'x')
columns  1: ('o', 'x', 'o')
columns  2: ('o', 'x', 'o')
rdiag    0: ('o', 'x', 'x')
ldiag    0: ('o', 'x', 'o')

This makes writing a function to find winners extremely simple:

def find_winners(board):
    for trav, i, group in traverse(board):
        won, pl = winner(group)
        if won:
            yield trav, i, pl
            
print(
    *(f'{pl!r} won on {trav.__name__: <8} {i}'
    for trav, i, pl in find_winners(board)),
    sep='\n'
)
'o' won on rows     0
'x' won on rows     1

Wrap-Up#

When designing APIs, we need to keep in mind all of the parts that will be used in a similar way. From there, we formulate automation around those pieces since we know they will provide a consistent/interchangeable output.

But does this code hold up to the challenge of changing constraints? Next week, we’ll revisit some aspects of the code and see if it’s flexible enough to create a 4x4 version of the game.

Until then, do you see any aspects that would prevent us from simply adding another row and column? Let us know on our Discord server. We’d love to hear your thoughts and thought process!

Discord Server: https://discord.gg/ZhJPKYSfNp

Until next time!