alexmojaki / futurecoder

100% free and interactive Python course for beginners
https://futurecoder.io/
MIT License
1.31k stars 139 forks source link

Tic tac toe #3

Closed alexmojaki closed 3 years ago

alexmojaki commented 4 years ago

Currently the course only contains isolated bite-sized exercises. Eventually students should work on a larger project. This is one idea for that.

Below is a simple implementation of a game to be played by 2 humans. It's broken up into several small functions which is helpful for many reasons. It could probably be broken up even further. For some functions I've provided several implementations at different levels using different concepts.

This project could also be taken to another level where the user has to implement automatic computer opponents, at least one which is completely random and one which is random except when there is an opportunity to win immediately. The two computer players plus the human player could be implemented as 3 classes in a hierarchy or as a case for higher order functions.

These are the minimal concepts that users will need:

Here's the code:

def row_winner(board):
    """
    Version that we don't want to allow
    """
    if ['O', 'O', 'O'] in board:
        return 'O'
    if ['X', 'X', 'X'] in board:
        return 'X'

def row_winner(board):
    """
    Basic version
    """
    result = None
    for row in board:
        piece = row[0]
        if piece != None and piece == row[1] and piece == row[2]:
            result = piece
    return result

def row_winner(board):
    """
    Improved version using:
        Ending a loop with return
        Falsiness of None
        Chaining ==
    """
    for row in board:
        piece = row[0]
        if piece and piece == row[1] == row[2]:
            return piece

def column_winner(board):
    """
    This can have a basic version just like row_winner
    This version is like the improved row_winner
    """
    for col in range(3):
        piece = board[0][col]
        if piece and piece == board[1][col] and piece == board[2][col]:
            return piece

def column_winner(board):
    """
    Improved version using:
        enumerate
        Chaining ==
    """
    for col, piece in enumerate(board[0]):
        if piece and piece == board[1][col] == board[2][col]:
            return piece

def column_winner(board):
    """
    Ultimate version :D
    """
    return row_winner(zip(*board))

def diagonal_winner(board):
    piece = board[1][1]
    if (
            (piece == board[0][0] and piece == board[2][2]) or
            (piece == board[0][2] and piece == board[2][0])
    ):
        return piece

def winner(board):
    piece = row_winner(board)
    if piece != None:
        return piece
    else:
        piece = column_winner(board)
        if piece != None:
            return piece
        else:
            piece = diagonal_winner(board)
            if piece != None:
                return piece

def winner(board):
    """
    Improved version using:
        Falsiness of None
        Exiting by return
    """
    piece = row_winner(board)
    if piece:
        return piece

    piece = column_winner(board)
    if piece:
        return piece

    piece = diagonal_winner(board)
    if piece:
        return piece

def winner(board):
    """
    Best version using:
        Actual meaning of or
    """
    return row_winner(board) or column_winner(board) or diagonal_winner(board)

def print_board(board):
    """
    Using print() makes it easier to write this kind of thing
    instead of print(format_board())
    format_board() is easier to test but I wrote this a while back before
    I realised I would be testing user code that uses print() anyway.
    However in theory it might be nice to show the user actual simple tests
    that they are aiming to pass, and teach them a bit about unit testing.
    The other functions can already do this nicely.
    """
    for row in board:
        line = ''
        for c in row:
            if c == None:
                c = ' '
            line += c
        print(line)

def format_board(board):
    """
    Equivalent of print_board above
    Returns something like:

    XOX
     OO
     X
    """
    result = ''
    for row in board:
        for c in row:
            if c == None:
                c = ' '
            result += c
        result += '\n'
    return result

def format_board(board):
    """
    Improved version using:
        range(len())
        Converting int to str for concatenation
        Falsiness of None
        Actual meaning of or

    Returns something like:
     123
    1XOX
    2 OO
    3 X 
    """
    result = ' 123\n'
    for i in range(len(board)):
        row = board[i]
        result += str(i + 1)
        for c in row:
            result += c or ' '
        result += '\n'
    return result

def format_board(board):
    """
    Extreme version using:
        Comprehension (nested)
        join
        enumerate

    Returns something like:
      1 2 3
    1 X|O|X 
      -+-+-
    2  |O|O
      -+-+-
    3  |X| 
    """
    return '  1 2 3\n' + '\n  -+-+-\n'.join(
        str(i + 1) + ' ' + '|'.join(
            c or ' '
            for c in row
        )
        for i, row in enumerate(board)
    )

def get_coordinate(typ):
    result = input(f'Enter {typ}: ')
    while result not in ('1', '2', '3'):
        print('Invalid ' + typ)
        result = input(f'Enter {typ}: ')
    return int(result) - 1

def get_coordinate(typ):
    """
    Improved version using:
        while True
        Ending a loop with return
    """
    while True:
        result = input(f'Enter {typ}: ')
        if result in ('1', '2', '3'):
            return int(result) - 1
        print('Invalid ' + typ)

def get_coordinate(typ):
    """
    Alternative improved version not requiring 'in', using str.isdigit and <=
    """
    while True:
        result = input(f'Enter {typ}: ')
        if result.isdigit():
            result = int(result)
            if 1 <= result and result <= 3:
                return result - 1
        print('Invalid ' + typ)

def get_free_coordinates(board):
    while True:
        row = get_coordinate('row')
        col = get_coordinate('column')
        if board[row][col]:
            print("That spot is taken")
        else:
            return row, col

def next_player(current_player):
    if current_player == 'X':
        return 'O'
    else:
        return 'X'

def main():
    """
    This plays one game. This function could be wrapped
    in a loop that asks if the player wants to play again.
    """
    # Alternatively empty cells could be represented by ' '
    # This makes format_board nicer but makes the best versions
    # of other functions less elegant
    board = [
        [None, None, None],
        [None, None, None],
        [None, None, None],
    ]
    player = 'X'
    for _ in range(9):
        print(format_board(board))
        w = winner(board)
        if w:
            print(w + ' wins!')
            # Or break and have a tracking variable to check for draw
            # Definitely not gonna use for/else
            return
        print(player + ' to play')

        # If the user is not ready for tuple unpacking
        # then this function can just be inlined
        # and the while loop can be ended by break instead of return
        row, col = get_free_coordinates(board)
        board[row][col] = player
        player = next_player(player)

    print(format_board(board))
    print("It's a draw!")

main()
alexmojaki commented 4 years ago

There might also be some good ideas here when I get around to reading it: https://www.daniellowengrub.com/blog/2020/02/08/python-for-beginners

spamegg1 commented 4 years ago

Hey @alexmojaki , I'm writing a Markdown draft for this. I have a question. diagonal_winner does NOT check if piece is None or not, but row_winner and column_winner do. Then inside winner there is None checking for all three functions, necessarily. The implication is that if the return statements are not triggered, Python will make them return None. I can certainly explain this if it wasn't covered before. Since we have to check all three inside winner anyway, it feels like making all three consistent with each other would be better. What do you think? Should I make all three check for None, or make none of them check, or leave it as it is? I would prefer leaving all the checks to winner because the others are redundant and this would make them simpler (as long as the user is aware when those functions return None).

alexmojaki commented 4 years ago

I think I have an easier way to write these functions. Don't write a draft yet.

In any case we still need to cover these concepts first at a minimum:

alexmojaki commented 3 years ago

I think I have an easier way to write these functions.

OK, so let me explain. In the combining booleans chapter, diagonal_winner only has to return whether or not a winner is present, which is easier to write. And that's all we really need, because if there's a winner, it's obviously whoever just played. So for example they could write:

def row_winner(board):
    result = False
    for row in board:
        piece = row[0]
        if piece != ' ' and piece == row[1] and piece == row[2]:
            result = True
    return result

def winner(board):
    # Unlike before, this becomes the only sensible way
    return row_winner(board) or column_winner(board) or diagonal_winner(board)

def main():
    ...
    player = 'X'
    for _ in range(9):
        ...
        if winner(board):
            print(player + ' wins!')

I think doing this to make the project easier is a good thing.

Regarding your question: the fewer concepts they are required to understand, the better. So let's avoid implicitly returning None or the falsiness of None. With that, I don't think there's much reason to use None at all, so maybe the board should start out filled with spaces instead of None, which also makes it easy to write format_board. On a similar note, let's not mention enumerate or zip.

Also, since we have established unit tests as part of the course, it should be format_board, not print_board. But that means we need to teach newlines, so maybe that should go in the 'more about strings' chapter about quotes and f-strings.

spamegg1 commented 3 years ago

Nice! I like those changes. I'll get to it after my current assignment.

alexmojaki commented 3 years ago

Ok, I looked through the draft in #92. Let's keep that level of discussion here, I was only taking about it there as it related to other pages that will be in the course.

Unfortunately it seems like you forgot about my comment above, where I said the functions should return booleans, not strings. They only have to detect the presence of a winner, not who won. That drastically changes what you wrote, so I haven't looked at the whole thing in detail.

Also dumping some other thoughts:

Maybe one last part of the project can be iterable unpacking. At the end of the current design there will be some code like this:

coordinates = get_free_coordinates(board)
row = coordinates[0]
column = coordinates[1]

Ultimately we want

row, column = get_free_coordinates(board)

On second thought, this is pushing things a bit. Maybe we should just simplify that whole part to:

def play_move(board, player):
    while True:
        row = get_coordinate('row')
        col = get_coordinate('column')
        if board[row][col]:
            print("That spot is taken")
        else:
            board[row][col] = player
            return

format_board needs assertions with strings containing newlines. How do we do that in a student friendly way? The simplest code to understand is:

assert_equal(
    format_board([
        ['X', 'O', 'X'],
        ['O', ' ', ' '],
        [' ', 'X', 'O']
    ]),
    "XOX\nO  \n XO"
)

but it completely hides why we format the board that way.

Do we split the string into several?

assert_equal(
    format_board([
        ['X', 'O', 'X'],
        ['O', ' ', ' '],
        [' ', 'X', 'O']
    ]),
    "XOX\n" +
    "O  \n" +
    " XO"
)

That looks pretty ugly, and is screaming for a triple quoted string. But then we have another thing to teach, and the simplest version of that is ugly too:

assert_equal(
    format_board([
        ['X', 'O', 'X'],
        ['O', ' ', ' '],
        [' ', 'X', 'O']
    ]),
"""XOX
O
 XO""")

To make it better requires more concepts:

assert_equal(
    format_board([
        ['X', 'O', 'X'],
        ['O', ' ', ' '],
        [' ', 'X', 'O']
    ]),
"""\
XOX
O
 XO""")

assert_equal(
    format_board([
        ['X', 'O', 'X'],
        ['O', ' ', ' '],
        [' ', 'X', 'O']
    ]),
"""
XOX
O
 XO
""".strip())

Also I'm assuming that the result doesn't end in a newline, but that makes things a bit harder, maybe it should be allowed?

Finally I think the initial format_board should have no numbers on the side. Adding numbers can be an extra exercise, maybe it should even be a bonus challenge.

spamegg1 commented 3 years ago

Haha yeah sorry! The discussion is split all over the place so it seems I forgot. I think I'll just edit/update my draft to return booleans.

I prefer your second thought. (I think we may consider teaching tuples maybe somewhere else, then change this later to reflect it, like we did with f-strings.)

I think it's best to stick to the simplest "XOX\nO \n XO" first. I think it's important for them to deal with a single-line string with a bunch of \ns in it. They will encounter this a lot in the wild. Once the user gets a working playable version of the game, we can suggest one of the other options as an improvement and further polish.

spamegg1 commented 3 years ago

I guess I should post the draft here?

(Outline at the beginning can be changed, I can remove the row-column numbers 123 and the names of the functions can be changed/added... I'm not too clear on the function names in the final build)

Tic-tac-toe Page 1, second draft (returning booleans this time)

Click to expand # Tic-Tac-Toe Project ### Page 1: Project Outline, `winner` #### Step 1: Intro and `row_winner` (ExerciseStep) In this large project you will develop a text-based interactive tic-tac-toe game to be played by 2 human players. Here is a small preview of what the finished game will look like in play: ```python 123 1 2 3 X to play Enter row: 2 Enter column: 2 123 1 2 X 3 O to play Enter row: 1 Enter column: 3 123 1 O 2 X 3 ``` We will break up the project into several small functions, which will be exercises. You will use many of the concepts you have learned so far: strings, indexing and subscripting with lists, nested lists, nested loops, `range`, calling functions within functions, comparisons, and booleans. Along the way you will also learn some new concepts: `input`, the newline character, types in Python, and `while` loops. Here is a rough outline of the project: - three functions `row_winner`, `column_winner`, `diagonal_winner` that check the whole board for winning rows, columns, diagonals respectively - a function `winner` that checks the whole board for a winner, combining the above functions - a function `format_board` that displays the current state of the game - a function `get_coordinate` that takes user input to play a move, - a function `next_player` that tracks which player's turn to play it is, - finally a `main` function that puts it all together and runs the game interactively. - Later on we will add further improvements. Let's get started! As before we will represent the tic-tac-toe board as a nested list, consisting of 3 lists (rows). Each row is a list of size 3. Each position in a row can be one of: `'X'`, `'O'`, or `' '` (which stands for an empty square). For example: ```python board = [ ['X', 'O', 'X'], ['O', ' ', ' '], [' ', 'X', 'O'] ] ``` Write a function `row_winner`, which takes an argument `board` like above and returns `True` if `board` contains a winning row of `'X'`s or `'O'`s, `False` otherwise. One naive way to achieve this is: ```python def row_winner(board): return ['O', 'O', 'O'] in board or ['X', 'X', 'X'] in board ``` This solution might loop through `board` twice (once for each `if` statement), which is unnecessary. I'd like you to find a better solution. In your solution you should loop through `board` only once, and make no references to `'X'` or `'O'` (you can refer to `' '`). Hints: *You need to check every row, you'll need a loop.* *How can you tell if a row is winning without using `'X'` or `'O'` directly?* *All the entries in a row should be the same.* *For each row, check if the 3 entries are equal to each other. If so, return `True` (stopping the loop early).* *Keep in mind that some entries might be `' '`. An empty row is not a winning row.* *Remember to return `False` when there are no winning rows!* *Solution:* ```python def row_winner(board): for row in board: piece = row[0] if piece != ' ' and piece == row[1] and piece == row[2]: return True return False ``` *user writes function, runs code, reaches next Step* #### Step 2: generalize `row_winner` to arbitrary size (ExerciseStep) Good job! A basic solution looks like: ```python __copyable__ def row_winner(board): for row in board: piece = row[0] if piece != ' ' and piece == row[1] and piece == row[2]: return True return False ``` Note that we can simplify `piece == row[1] and piece == row[2]` by *chaining them together* as `piece == row[1] == row[2]`. (We cannot do the same with `or`.) Currently our solution depends on each row having 3 entries. Now I'd like you to generalize your solution to boards of *any size*. The input `board` is still a nested list where each sublist (row) consists of `'X'`s, `'O'`s and `' '`s. But the numbers of rows and columns can be anything. For example: ```python board = [ ['X', ' ', 'O', 'X', ' ', 'X', ' '], [' ', ' ', 'X', ' ', 'O', 'O', 'O'], ['X', ' ', ' ', 'X', ' ', ' ', ' '], ['O', ' ', 'O', 'X', ' ', 'X', 'X'] ] ``` This is a four-by-seven example but your solution should work for any size. A winning row would be either all `'X'`s or all `'O'`s. As before, you are not allowed to use `'X'` or `'O'` directly in your solution. *Hints:* *The overall structure of the solution is similar; you need a loop to check every row. However you will have to change the code inside the loop significantly.* *How can you check if all entries in a row are equal to each other?* *You can no longer do something like `piece == row[1] == row[2]` to check for a winning row, because you don't know how long a row is.* *If you don't know the size of a row, you'll have to loop all the way through it.* *You can still use `piece = row[0]` to compare all the row entries to it.* *For each row, define a boolean. Then loop through that row, updating the boolean accordingly.* *Think carefully about what the initial value of the boolean should be, and under what conditions you should change its value.* *After looping through a row, if you determined that all its entries are equal, then return `True` (ending the outer loop early).* *Once again, be on the lookout for `' '` and remember to return `False` if there are no winning rows!* *Solution:* ```python def row_winner(board): for row in board: all_equal = True piece = row[0] for entry in row: if entry == ' ' or piece != entry: all_equal = False if all_equal: return True return False ``` *user edits code, runs, reaches next Step* #### Step 3: `column_winner` (ExerciseStep) Excellent! The generalized version looks like: ```python def row_winner(board): for row in board: all_equal = True piece = row[0] for entry in row: if entry == ' ' or piece != entry: all_equal = False if all_equal: return True return False ``` Now let us move on to the next function. For the moment we are back to 3-by-3 boards. Write a function `column_winner`, which takes an argument `board` which is a nested list representing a 3-by-3 board, and returns `True` if `board` contains a winning *column* of `'X'`s or `'O'`s, `False` otherwise. (Once again do not refer to `'X'` or `'O'` directly in your solution.) Hints: *You can start by imitating the 3-by-3 version of `row_winner` above, then change it to make it work with columns.* *You can't loop through the columns of `board` as simply as its rows.* *To loop through the columns, what should be the bounds of your loop?* *What should `piece` be? How do we access the other 2 entries that are on the same column?* *Remember that `board` is a nested list, so you'll have to use double subscripting to access what you need.* *As before, remember to check for `' '`s and to return `False` if necessary.* *Solution:* ```python def column_winner(board): for col in range(3): piece = board[0][col] if piece != ' ' and piece == board[1][col] == board[2][col]: return True return False ``` *user writes function, runs code, reaches next Step* #### Step 4: generalize `column_winner` to arbitrary size (ExerciseStep) Great work! A basic solution looks like: ```python def column_winner(board): for col in range(3): piece = board[0][col] if piece != ' ' and piece == board[1][col] == board[2][col]: return True return False ``` (Notice that we incorporated *equality chaining* into the code.) Now just like before I'd like you to generalize `column_winner` to boards of arbitrary size. *Hints:* *You can start with the generalized version of `row_winner` from before and try to make it work with columns.* *To see what kind of changes you need, look at `column_winner` above.* *As before you'll need nested loops.* *The outer loop will go through columns. This is very similar to `column_winner` above, with a small change (to the bounds of the loop).* *How do you find the number of columns in `board`?* *The inner loop will be used to check each column's entries.* *You can still use `piece = board[0][col]` to compare all the column entries to it.* *Define a boolean for each column, then update it accordingly inside the inner loop.* *The different entries of a column are NOT on the same row. So how can you access them?* *To access all the entries of, say, the 5th column, you can loop through all the rows, and access the 5th element in each row.* *The rest of the logic is very similar to `row_winner`. Watch out for `' '` and remember to return `False` at the end if needed!* *Solution:* ```python def column_winner(board): for col in range(len(board[0])): all_equal = True piece = board[0][col] for row in board: if row[col] == ' ' or row[col] != piece: all_equal = False if all_equal: return True return False ``` *user writes function, runs code, reaches next Step* #### Step 5: generalize `diagonal_winner` to arbitrary sized square boards (ExerciseStep) Excellent! That was challenging. The solution looks like: ```python def column_winner(board): for col in range(len(board[0])): all_equal = True piece = board[0][col] for row in board: if row[col] == ' ' or row[col] != piece: all_equal = False if all_equal: return True return False ``` Finally we need to check for winning diagonals. You already wrote a function to do just that in the previous chapter, for 3-by-3 boards: ```python def diagonal_winner(board): middle = board[1][1] return ( (middle == board[0][0] and middle == board[2][2]) or (middle == board[0][2] and middle == board[2][0]) ) ``` Now write a generalized version of `diagonal_winner` for *square boards* of arbitrary size. Your function should work for any square sizes: 4-by-4, 5-by-5, and so on... *Hints:* *How many diagonals are there on a square board of arbitrary size?* *Even if the size of the board changes, the number of diagonals remains the same!* *You can't do something like `middle == board[0][0] and middle == board[2][2]` this time, because you don't know how long a diagonal is.* *Moreover the two diagonals might not have anything in common like `middle`.* *First, focus on the diagonal that goes from top left to bottom right.* *How can you access those entries with double subscripting?* *Do you see a pattern in those double subscripts? Get some paper and pen, work it out on some examples.* *Now focus on the other diagonal (from top right to bottom left). There is a pattern in the subscripts again, but it's a little bit more difficult.* *Do you remember negative indexing? It might be helpful here.* *Once you get the hang of the patterns, use the same ideas from before to check if all entries are equal.* *For the top-left to bottom-right diagonal, you can use the top-left board entry`board[0][0]` to compare all the entries to it. Something similar for the other diagonal...* *For each diagonal define a boolean, and use a loop to go through the entries in that diagonal, updating that diagonal's boolean accordingly.* *Solution:* ```python def diagonal_winner(board): all_equal1 = True all_equal2 = True topleft = board[0][0] topright = board[0][-1] for i in range(len(board)): if board[i][i] == ' ' or board[i][i] != topleft: all_equal1 = False if board[i][-i-1] == ' ' or board[i][-i-1] != topright: all_equal2 = False return all_equal1 or all_equal2 ``` *user writes function, runs code, reaches next Step* #### Step 6: generalized `winner` (ExerciseStep) Bravo! That was quite tough. A typical solution looks like: ```python def diagonal_winner(board): all_equal1 = True all_equal2 = True topleft = board[0][0] topright = board[0][-1] for i in range(len(board)): if board[i][i] == ' ' or board[i][i] != topleft: all_equal1 = False if board[i][-i-1] == ' ' or board[i][-i-1] != topright: all_equal2 = False return all_equal1 or all_equal2 ``` Now we can put the three functions together! Write a function `winner` that takes an argument `board`, which is a nested list representing a *square board* of arbitrary size, and returns `True` if `board` contains either a winning row, column or diagonal of `'X'`s or `'O'`s, `False` otherwise. You can freely use the generalized versions of `row_winner`, `column_winner` and `diagonal_winner` functions (which we added to your environment) without typing their codes. *Think about possible cases. When should `winner(board)` return `False`? When does it return `True`?* *How can you use the three functions and a boolean operator together to get the result you need?* *Solution:* ```python def winner(board): return row_winner(board) or column_winner(board) or diagonal_winner(board) ``` #### Final Text Great! The solution looks like: ```python def winner(board): return row_winner(board) or column_winner(board) or diagonal_winner(board) ``` Now we have the code to determine a winning state on the board. Next we will tackle the problem of displaying the board on the screen. *user reaches next Page*
alexmojaki commented 3 years ago

Don't feel bad for forgetting that comment, no one is perfect.

I'm thinking now that the next_player function is probably micromanaging and overkill. They can structure the main function however they'd like, making more smaller functions if they want. That's a skill they need to practice.

This solution might loop through board twice (once for each if statement)

There are no if statements now.

Keep in mind that some entries might be ' '. An empty row is not a winning row.

Sounds like a message step.

The row_winner solution shouldn't be __copyable__. We want the user to study the solution and compare it to their own, maybe editing their solution.

We will need to teach about ending a function with an early return in the functions chapter.

You can even write the chain as if ' ' != piece == row[1] == row[2]:. I don't know why you say "We cannot do the same with or" since there's no or in the function.

I think it might be better if we just go straight to the general version. It saves time by getting straight to the point, especially if the user is tempted to solve the initial version with something like board[0][0] == board[0][1] == board[0][2] or board[1][0] == .... I also think that this size of problem is a good match with their current skills in breaking down and solving problems, i.e. doable but still challenging. If they're not ready, a hint can suggest thinking about the 3x3 version first. Meanwhile the project is showing them how to break down an even larger problem. What do you think?

Maybe rather than saying they can't use literals 'X' or 'O', we should require that the solution works for any characters as pieces, except ' ' of course.

All of these functions should have tests using assert_equal.

It's not always necessary to show the user a solution after they complete an exercise. After all, they already have a solution. I think showing the generalised row_winner is not very useful, since you don't say anything about it.

spamegg1 commented 3 years ago

Hmm OK... some of those irrelevant texts are left over from the previous version, my bad. Even though I've read through it twice after changing it to return booleans, I still missed them.

I wasn't sure what I thought about the difficulty. First I thought the difficulty level was too high. But hey, I'm all for getting rid of the 3x3 versions if you think they are capable! I think I need to redo the course every now and then to "calibrate" my sense of where the user would be skill-wise. It's true the previous chapters had some tough stuff. They should be able to handle it; plus there are tons of hints.

In terms of breaking down, I was thinking of maybe having them write separate functions to check an individual row/column/diagonal, then use that in the bigger function? This is probably not a good idea; first of all as you said we want them to practice this skill on their own; second the functions are not that long anyway.

Right, assert_equal tests come with the text. Again I should re-read the latest chapters.

I'd like to make one more version of the draft before moving forward.

spamegg1 commented 3 years ago

Page 1, third draft: removed 3x3 versions of functions, made suggested changes, added assert_equal tests... hopefully I haven't forgotten something.

Click to expand # Tic-Tac-Toe Project ### Page 1: Project Outline, `winner` #### Step 1: Intro and generalized `row_winner` (ExerciseStep) In this large project you will develop a text-based interactive tic-tac-toe game to be played by 2 human players. Here is a small preview of what the finished game will look like in play: ```python 123 1 2 3 X to play Enter row: 2 Enter column: 2 123 1 2 X 3 O to play Enter row: 1 Enter column: 3 123 1 O 2 X 3 ``` We will break up the project into several small functions, which will be exercises. You will use many of the concepts you have learned so far: strings, indexing and subscripting with lists, nested lists, nested loops, `range`, calling functions within functions, comparisons, and booleans. Along the way you will also learn some new concepts: `input`, the newline character, types in Python, and `while` loops. Here is a rough outline of the project: - three functions `row_winner`, `column_winner`, `diagonal_winner` that check the whole board for winning rows, columns, diagonals respectively - a function `winner` that checks the whole board for a winner, combining the above functions - a function `format_board` that displays the current state of the game - a function `get_coordinate` that takes user input to play a move, - a function `next_player` that tracks which player's turn to play it is, - finally a `main` function that puts it all together and runs the game interactively. - Later on we will add further improvements. Let's get started! As in the last chapter, we will represent the tic-tac-toe board as a nested list, consisting of 3 sublists (rows). Each row is a list of size 3. Each position in a row can be one of: `'X'`, `'O'`, or `' '` (which stands for an empty square). For example: ```python board = [ ['X', 'O', 'X'], ['O', ' ', ' '], [' ', 'X', 'O'] ] ``` We want a function `row_winner`, which takes an argument `board` like above and returns `True` if `board` contains a winning row of either piece, `False` otherwise. One naive way to achieve this is: ```python def row_winner(board): return ['O', 'O', 'O'] in board or ['X', 'X', 'X'] in board ``` There are some issues with this solution: it won't work for pieces other than `'X'` and `'O'`, and it won't work for boards of other sizes. Also this approach won't work for `column_winner` or `diagonal_winner` later. I'd like you to find a better solution. Write a more general version of `row_winner` that works for any two different characters as pieces (except `' '` of course), and for boards of arbitrary size. For example: ```python board = [ ['A', ' ', 'B', 'A', ' ', 'A', ' '], [' ', ' ', 'A', ' ', 'B', 'B', 'B'], ['A', ' ', ' ', 'A', ' ', ' ', ' '], ['B', ' ', 'B', 'A', ' ', 'A', 'A'] ] ``` This is a four-by-seven example but your solution should work for any size. We are still using `' '` to denote empty squares. A winning row would have the same character in all its entries (except `' '`). Here are some tests it should pass (feel free to add more): ```python __copyable__ assert_equal( row_winner( [ ['A', 'A', 'B', 'A', 'A', 'A', 'A'], [' ', ' ', ' ', ' ', ' ', ' ', ' '], ['A', ' ', ' ', 'A', ' ', ' ', ' '], ['B', ' ', 'B', 'A', ' ', 'A', 'A'] ] ), False ) assert_equal( row_winner( [ ['M', ' '], ['S', 'M'], [' ', 'S'], ['M', 'S'], ['S', 'S'] ] ), True ) ``` *Hints:* *You need to check every row, so you'll need a loop.* *How can you check if all entries in a row are equal to each other?* *If you don't know the size of a row, you'll have to loop all the way through it.* *So you'll end up using nested loops!* *For each row, define a boolean. Then loop through that row, updating the boolean accordingly.* *You can use the first entry `row[0]` in a row to compare all the row entries to it.* *Think carefully about what the initial value of the boolean should be, and under what conditions you should change its value.* *After looping through a row, if you determined that all its entries are equal, then return `True` (ending the outer loop early).* *Be on the lookout for `' '` and remember to return `False` if there are no winning rows!* ##### MessageStep: if they forgot `entry == ' '` *Keep in mind that some entries might be `' '`. An empty row is not a winning row.* *Solution:* ```python def row_winner(board): for row in board: all_equal = True piece = row[0] for entry in row: if entry == ' ' or piece != entry: all_equal = False # should I add break here? if all_equal: return True return False ``` *user writes function, runs, reaches next Step* #### Step 2: generalized `column_winner` (ExerciseStep) Great job! Now let us move on to the next function. Write a function `column_winner`, which takes an argument `board` as before (of arbitrary size and any two different characters as pieces), and returns `True` if `board` contains a winning *column* of either piece, `False` otherwise. Again we provide some tests, add more of your own: ```python __copyable__ assert_equal( column_winner( [ ['A', 'B', 'B', 'A', 'A', 'A', ' '], ['A', 'B', ' ', ' ', ' ', ' ', ' '], ['A', 'B', 'B', 'A', ' ', ' ', ' '], ['B', 'A', 'B', 'A', ' ', 'A', ' '] ] ), False ) assert_equal( column_winner( [ ['M', 'S'], ['S', 'S'], [' ', 'S'], ['M', 'S'], ['S', 'S'] ] ), True ) ``` Hints: *You can start by imitating `row_winner` above, then change it to make it work with columns.* *You can't loop through the columns of `board` as simply as its rows.* *To loop through the columns, what should be the bounds of your loop?* *How do you find the number of columns in `board`?* *Remember that `board` is a nested list, so you'll have to use double subscripting to access what you need.* *Define a boolean for each column, then update it accordingly inside the inner loop.* *For each column, what should `piece` be?* *The different entries of a column are NOT on the same row. So how can you access them?* *To access all the entries of, say, the 5th column, you can loop through all the rows, and access the 5th element in each row.* *The rest of the logic is very similar to `row_winner`. Watch out for `' '` and remember to return `False` at the end if needed!* *Solution:* ```python def column_winner(board): for col in range(len(board[0])): all_equal = True piece = board[0][col] for row in board: if row[col] == ' ' or row[col] != piece: all_equal = False # should I add break here? if all_equal: return True return False ``` *user writes function, runs code, reaches next Step* #### Step 3: generalize `diagonal_winner` to arbitrary sized square boards (ExerciseStep) Excellent! That was challenging. Finally we need to check for winning diagonals. You already wrote a function to do just that in the previous chapter, for 3-by-3 boards: ```python def diagonal_winner(board): middle = board[1][1] return ( (middle == board[0][0] and middle == board[2][2]) or (middle == board[0][2] and middle == board[2][0]) ) ``` Now write a generalized version of `diagonal_winner` for *square boards* of arbitrary size. Your function should work for any square sizes: 4-by-4, 5-by-5, and so on... Some tests are provided, add more of your own tests: ```python __copyable__ assert_equal( diagonal_winner( [ ['X', 'X', 'O', ' '], [' ', 'X', ' ', ' '], ['X', ' ', 'O', 'X'], [' ', ' ', 'O', 'X'] ] ), False ) assert_equal( diagonal_winner( [ ['M', ' ', 'S', ' ', 'S'], ['S', 'M', 'S', 'S', ' '], [' ', 'S', 'S', ' ', ' '], ['M', 'S', 'S', 'M', 'M'], ['S', 'S', 'S', 'S', 'S'] ] ), True ) ``` *Hints:* *How many diagonals are there on a square board of arbitrary size?* *Even if the size of the board changes, the number of diagonals remains the same!* *You can't do something like `middle == board[0][0] and middle == board[2][2]` this time, because you don't know how long a diagonal is.* *Moreover the two diagonals might not have anything in common like `middle`.* *First, focus on the diagonal that goes from top left to bottom right.* *How can you access those entries with double subscripting?* *Do you see a pattern in those double subscripts? Get some paper and pen, work it out on some examples.* *Now focus on the other diagonal (from top right to bottom left). There is a pattern in the subscripts again, but it's a little bit more difficult.* *Do you remember negative indexing? It might be helpful here.* *Once you get the hang of the patterns, use the same ideas from before to check if all entries are equal.* *For the top-left to bottom-right diagonal, you can use the top-left board entry`board[0][0]` to compare all the entries to it. Something similar for the other diagonal...* *For each diagonal define a boolean, and use a loop to go through the entries in that diagonal, updating that diagonal's boolean accordingly.* *Solution:* ```python def diagonal_winner(board): all_equal1 = True all_equal2 = True topleft = board[0][0] topright = board[0][-1] for i in range(len(board)): if board[i][i] == ' ' or board[i][i] != topleft: all_equal1 = False if board[i][-i-1] == ' ' or board[i][-i-1] != topright: all_equal2 = False return all_equal1 or all_equal2 ``` *user writes function, runs code, reaches next Step* #### Step 4: generalized `winner` (ExerciseStep) Bravo! That was quite tough. Now we can put the three functions together! Write a function `winner` that takes an argument `board`, which is a nested list representing a *square board* of arbitrary size, and returns `True` if `board` contains either a winning row, column or diagonal, `False` otherwise. You can freely use the generalized versions of `row_winner`, `column_winner` and `diagonal_winner` functions (which we added to your environment) without typing their codes. Here are some tests, feel free to add more: ```python __copyable__ assert_equal( winner( [ ['X', 'X', 'X', ' '], ['X', 'X', ' ', ' '], ['X', ' ', 'O', 'X'], [' ', ' ', 'O', 'X'] ] ), False ) assert_equal( winner( [ ['M', ' ', 'S', ' ', 'S'], ['S', 'M', 'S', 'S', ' '], [' ', 'S', 'S', ' ', ' '], ['M', 'S', 'S', 'M', 'M'], ['S', 'S', 'S', 'S', 'S'] ] ), True ) assert_equal( winner( [ ['A', ' '], ['A', 'B'] ] ), True ) ``` *Hints:* *Think about possible cases. When should `winner(board)` return `False`? When does it return `True`?* *How can you use the three functions and a boolean operator together to get the result you need?* *Solution:* ```python def winner(board): return row_winner(board) or column_winner(board) or diagonal_winner(board) ``` *user writes function, runs, reaches next Step* #### Final Text Great work! Now we have the code to determine a winning state on the board. Next we will tackle the problem of displaying the board on the screen. *user reaches next Page*
alexmojaki commented 3 years ago

Change the text near the beginning to:

As in the last chapter, we will represent the tic-tac-toe board as a nested list of strings. For a typical game this will be a 3x3 list, i.e. 3 lists each containing 3 strings, with players represented by 'X' or 'O'. Empty squares will be represented by a space, i.e. ' '. For example:

board = ...

However to make things more interesting your code will need to work for square boards of any size (4x4, 5x5, etc) where players can be represented by any two strings, e.g.

board = 4x4 using A and B

After that there's no need to mention the generalisation, except briefly to contrast diagonal_winner with the original version. Have one test in row_winner with pieces A and B just to make the point clear, but otherwise stick to X and O which are easier to read. Also keep them 3x3 and 4x4 and not too crowded. I say all this because for example this test is not easy to read:

assert_equal(
    diagonal_winner(
        [
            ['M', ' ', 'S', ' ', 'S'],
            ['S', 'M', 'S', 'S', ' '],
            [' ', 'S', 'S', ' ', ' '],
            ['M', 'S', 'S', 'M', 'M'],
            ['S', 'S', 'S', 'S', 'S']
        ]
    ),
    True
)

The tests not shown to the user should still have general sizes and pieces, including those made by generate_inputs.

should I add break here?

I think yes, to make the parsons problem more interesting.

I think it's best to stick to the simplest "XOX\nO \n XO" first. I think it's important for them to deal with a single-line string with a bunch of \ns in it. They will encounter this a lot in the wild.

You're right, it's worth feeling comfortable with this.

Once the user gets a working playable version of the game, we can suggest one of the other options as an improvement and further polish.

Well no, this is entirely about how the tests are written.

In terms of breaking down, I was thinking of maybe having them write separate functions to check an individual row/column/diagonal, then use that in the bigger function? This is probably not a good idea; first of all as you said we want them to practice this skill on their own; second the functions are not that long anyway.

I also wondered about this, a sort of all_equal function. I think you're right that we shouldn't need it.

I think this draft of page 1 is good enough for a PR. Do you want to make a PR for just the start of the project? There's a slight chance that writing/drafting subsequent pages will reveal some broader structural problem with the first page, but I think it'll be OK.

spamegg1 commented 3 years ago

OK, I predicted you'd ask me to shorten those tests.

Probably it will be easier on both of us if I make one PR per page. I thought about it too, we'll probably have to go back and change some things in earlier pages once the later pages are done. Let's not worry about it.

Going for the PR now...

spamegg1 commented 3 years ago

Wasn't sure if I should put this here or #92

Newlines and `format_board` ## Page: Newlines and `format_board` ### Step: introducing newlines (VerbatimStep) Next we want to tackle the problem of displaying the tic-tac-toe board. While playing the game, if we have a board state such as: ```python board = [ ['X', 'O', 'X'], [' ', 'O', 'O'], [' ', 'X', ' '] ] ``` we would like to print it as: ```python XOX OO X ``` To accomplish this, we could write a function that concatenates all the characters in each row, and then prints them. However it is better to `return` a string instead of printing, so that we can easily test the correctness of the function with `assert_equal`. So we'd like to build up a string that represents the whole board, and when printed, displays the board like above, with each row printed on a separate line. But how can we create a string that holds multiple lines of text in it? Thankfully the *newline* `\n` (a backslash \ followed by a lowercase n) allows us to do this in Python. When a string containing `\n` is printed, the part of the string after `\n` will be printed on the next line. For example, ````python print("Line 1\nLine 2") ```` will print ```python Line 1 Line 2 ``` A string can include as many `\n`s as we like. Each `\n` will add another line to what is displayed. Try it: ```python print("This is line 1\nThis is line 2\nThis is line 3\n") ``` *Predicted output choices:* - ```python This is line 1\nThis is line 2\nThis is line 3\n ``` - ```python This is line 1\n This is line 2\n This is line 3\n ``` - ```python This is line 1 This is line 2 This is line 3 ``` - ```python This is line 1 This is line 2 This is line 3 ``` - `Error` *user runs code, predicts output, reaches next Step* ### Step: `format_board` (simple version) (ExerciseStep) The two characters `\n` themselves are not printed. Moreover in this example the very last `\n` adds another line to what is displayed, even though that line is empty, because nothing else comes after that `\n`. We can add newlines to a string just like how we add any other characters, by concatenating `'\n'` to the string with `+`: ```python string = string + '\n' ``` Now that you know how to add newlines to strings, write a function `format_board` that takes a square `board` of *any size* and returns a single string that represents the board like above (there is a newline after the last row of the board too). We provided a test, you can write more tests yourself: ```python def format_board(board): ... assert_equal( format_board([ ['X', 'O', 'X'], ['O', ' ', ' '], [' ', 'X', 'O'] ]), 'XOX\nO \n XO\n' ) ``` *Hints:* *Look carefully at the test case we provided. It shows you all you need!* *You need to build up a string for the whole board. Start with an empty string.* *For each row, add the characters from that row to the string.* *You'll need nested loops.* *When you reach the end of a row, you need to go to the next line before the next row.* *Add a newline at the end of each row.* *Solution:* ```python def format_board(board): result = '' for row in board: for char in row: result += char result += '\n' return result ``` *user solves exercise, reaches next Step* ### Step: `format_board` bonus challenge (ExerciseStep) Excellent! If you'd like, you can just continue to the next page now. Or you can do a bonus challenge! Write an improved version of `format_board` that displays row and column numbers: for example, if we are given ```python board = [ ['X', 'O', 'X'], [' ', 'O', 'O'], [' ', 'X', ' '] ] ``` then `format_board` should return a string that, when printed, displays the board like ```python 123 1XOX 2 OO 3 X ``` Once again it should work for a square `board` of *any size*, and there is a newline after the last row like before. To add the row and column numbers as strings, you'll need Python's built-in `str` function. You haven't learned it yet, but it's part of the challenge! For this exercise, all you need to know is that if you give it a number, it converts the number to a string: `str(5)` returns the string `'5'`. Feel free to experiment with it in the shell. We provide one test as before: ```python def format_board(board): ... assert_equal( format_board([ ['X', 'O', 'X'], ['O', ' ', ' '], [' ', 'X', 'O'] ]), ' 123\n1XOX\n2O \n3 XO\n' ) ``` *Hints:* *Start with your `format_board` from the previous exercise.* *What else do you need to add to your existing solution?* *Once again you can examine the test case we provided for clues.* *How many rows and columns are there now, compared to before?* *There is one additional row, and one additional column compared to last time.* *Think about where in your code you should add the top row of column numbers.* *The top row will have to be handled first, separately from the nested loop that handles the rest.* *Notice that the top row always starts with a space.* *Then it has consecutive numbers in increasing order: `123456...`* *To add all these numbers, you will have to use a loop with `range`. Remember `range` starts counting from zero.* *If you use `+` between a string and a number you'll get an error!* *Use `str` to convert the numbers to strings.* *Don't forget to add a newline after you are done with the top row!* *To add a row number to each board row, modify the outer loop of your nested loop to use `range` instead.* *Add the row number first, then use the inner loop to add the rest of the characters like before.* *Solution:* ```python def format_board(board): result = ' ' for i in range(len(board)): result += str(i + 1) result += '\n' for i in range(len(board)): result += str(i + 1) for char in board[i]: result += char result += '\n' return result ``` *user reaches Final Text* ### Final Text Great work! That was quite challenging. Now you mastered how to build up a string of multiple lines of text, and solved the problem of displaying the board to the players. Next you will learn more about types in Python and how to convert them (like you did with `str` just now), and how to get input from the players. You are already about halfway done with the project. Keep going! *user reaches next Page*
spamegg1 commented 3 years ago

I decided to be proactive and started writing the PR. Ran into a bit of an issue with predicted output choices (of course if you don't like the exercise we can change it too):

This is line 1
This is line 2
This is line 3

and

This is line 1
This is line 2
This is line 3

are treated as the same. The radio button selects both of them when clicked. They both count as the correct choice.

I'm guessing this is a feature not a bug... it probably removes empty lines for a good reason. There might be a way of tricking it into accepting the empty line but I don't know. I tried hitting Enter, and tried adding \n. I should probably read the code :)

alexmojaki commented 3 years ago

Yes, futurecoder strips whitespace in lots of places, especially trailing whitespace. I think that's for the best, rendering whitespace is often difficult (Github isn't showing the difference in your draft) and students shouldn't have to look for a trailing newline. The difference between options should be obvious.

I'm realising now how tricky this is. I will write a draft explaining newlines in the first part. In fact I will even start with triple quoted strings.

spamegg1 commented 3 years ago

Interesting... I suppose that will change many things. For now I'll sit on it and do nothing, wait for your draft.

alexmojaki commented 3 years ago

OK here's a draft


Next we want to tackle the problem of displaying the tic-tac-toe board. Here's one way to do this:

__copyable__
def print_board(board):
    for row in board:
        print("".join(row))

print_board([
    ['X', 'O', 'X'],
    [' ', 'O', 'O'],
    [' ', 'X', ' ']
])

(What's "".join? Google it!)

[I know that in the past I've always made sure to introduce any new concept slowly and carefully, but I realise now that this hand-holding has to stop at some point so that students can learn to be independent and deal with seeing new and unfamiliar code on their own. Maybe this should have even been sooner.]

[Student runs code]

This is a good start but ideally we'd like a function which returns a string rather than printing it. This way other code can make easy use of the string in different ways. We might want to manipulate the string (e.g. draw a box around it or extract only the first few lines), we might want to send it somewhere other than the screen (e.g. a file) and in this particular case we want to be able to test it with assert_equal. This doesn't work:

assert_equal(print_board([...]), "...")

because print_board doesn't use return so it just returns None by default.

[The following section is overkill and could easily bore students. It should likely be excluded entirely. Maybe we could make it collapsible so that it's clearly optional?]

It's possible (though inconvenient) to get what print_board prints as a string. But this is also an example of some more general good practices in programming:

The print_board kind of strategy is easy and is sometimes good enough. But we'd like you to learn good habits that will serve you well in the long term.

[End of overkill section]

So instead we want code like this:

def format_board(board):
    ...
    return ...

assert_equal(format_board([...]), "...")

Then print(format_board(board)) should print something like what we saw at the beginning.

But how do we return a string with multiple lines? And how do we test it? We'd like to do something like this:

assert_equal(
    format_board([
        ['X', 'O', 'X'],
        [' ', 'O', 'O'],
        [' ', 'X', ' ']
    ]),
    "XOX
      OO
      X "
)

See for yourself how this doesn't work.

[Student runs code, we check for syntax error]

Normally a string literal has to be on one line, so this is invalid:

string = "First line
Second line"
print(string)

But Python provides a way! The solution is to use triple quotes, i.e. three quote characters in a row (either ''' or """) around the contents of the string:

string = """First line
Second line"""
print(string)

[Student runs code]

Hooray! A triple quoted string is allowed to span many lines and they will be shown in the output.

[Another collapsible section?]

The string literal is a bit ugly and doesn't really match the output. It would be nice if we could write:

print("""First line
         Second line""")

but any spaces inside quotes (even in an indented block such as for or def) are actual characters in the string which will be printed, see for yourself.

Alternatively you could write:

print("""
First line
Second line""")

but this actually prints three lines, the first one being blank.

[End of section]

Like single and double quotes, triple quotes are just another kind of notation, not a new kind of string. """abc""" is the same thing as "abc".

However string does contain something new. Run string in the shell to see.

[Student runs code. Must have a message to check that string has the right value, see word_must_be_hello in chapter 3]

There's the secret!

\n represents a newline character. This is just another character, like a letter or a space (' '). It's the character between two separate lines that you type in by pressing Enter on your keyboard.

Again, \n represents the newline character within a Python string literal. The string doesn't actually contain \ and n, it just contains one character. Check this in the shell:

len('\n')

[Student runs code, must predict output 1 or 2 ]

Now use the newline character to write the function format_board:

def format_board(board):
    ...

assert_equal(
    format_board([
        ['X', 'O', 'X'],
        ['O', ' ', ' '],
        [' ', 'X', 'O']
    ]),
    'XOX\nO  \n XO'
)

Because we've revealed join I'd like to hope that they use it, which is why I've removed the trailing newline. The official solution shouldn't use it but after they solve the exercise we can show a solution using join twice and leave it up to them to figure out how it works.

Maybe the 'collapsible sections' could be placed in the final_text as sort of afterthoughts?

spamegg1 commented 3 years ago

Nice work.

I'm OK with join being mentioned. We should allow it in the format_board solution as you said. The only issue is that the non-join solution will get a bit more complicated to exclude the final newline, but this is a pattern they've seen before many times (use a boolean and a loop, change boolean at some point...)

I agree the overkill section should probably be excluded entirely. I guess collapsible will be a new front-end feature? Can't remember seeing collapsible sections before. (We could go back and use it on the or section with the "Now I have additional questions" elephant picture, and maybe some others)

Yay for triple quotes! I wanted it originally.

The approach to "discover" the newline character is interesting. I guess they should be able to immediately use it. I hope they can immediately make the connection "Oh I can just += a newline just like any other string!".

Sure, we can include the collapsible parts in the final text as afterthoughts. We have to keep in mind that the final text comes after the bonus challenge, so the afterthoughts would be skippable that way. So maybe keep it collapsible before the bonus challenge?

alexmojaki commented 3 years ago

Nice work.

I'm OK with join being mentioned. We should allow it in the format_board solution as you said. The only issue is that the non-join solution will get a bit more complicated to exclude the final newline, but this is a pattern they've seen before many times (use a boolean and a loop, change boolean at some point...)

Except remember we actually discovered it's tricky to do with just a boolean and made it a bonus challenge. It can be made easier now by looping over the indices and checking if we're on the first/last index.

Also it's kinda nice that the non-join solution is longer because it means more work if they reveal either kind of solution.

I agree the overkill section should probably be excluded entirely. I guess collapsible will be a new front-end feature?

Yes. Anyway it's more of a future idea.

We could go back and use it on the or section with the "Now I have additional questions" elephant picture, and maybe some others

Definitely.

Yay for triple quotes! I wanted it originally.

Yeah, sorry about all the debate and the wasted draft. It's weird how complicated I've managed to make teaching one little concept.

The approach to "discover" the newline character is interesting. I guess they should be able to immediately use it. I hope they can immediately make the connection "Oh I can just += a newline just like any other string!".

I hope so too, I've given them plenty of opportunities to see that it's just a character. We can add a hint to make sure.

Sure, we can include the collapsible parts in the final text as afterthoughts. We have to keep in mind that the final text comes after the bonus challenge, so the afterthoughts would be skippable that way. So maybe keep it collapsible before the bonus challenge?

Ooooh, I forgot about that. Let's just leave them out for now.

spamegg1 commented 3 years ago

OK, I guess I should make an updated draft with these changes? Or go straight to PR? Going for PR...

alexmojaki commented 3 years ago

Last part finished in #162