Race to 21

Race to 21 is a simplified version of the game of Nim. The rules of the game are as follows.

There are two players. The initial sum is 0. In turn, the players can add either 1 or 2 to the sum. The player who makes the sum be 21 wins.

Here is an example of a game

SUM: 0
PLAYER 1: +2 (SUM is now 2)
PLAYER 2: +1 (SUM is now 3)
PLAYER 1: +2 (SUM is now 5)
PLAYER 2: +1 (SUM is now 6)
PLAYER 1: +1 (SUM is now 7)
PLAYER 2: +1 (SUM is now 8)
PLAYER 1: +2 (SUM is now 10)
PLAYER 2: +2 (SUM is now 12)
PLAYER 1: +2 (SUM is now 14)
PLAYER 2: +2 (SUM is now 16)
PLAYER 1: +2 (SUM is now 18)
PLAYER 2: +1 (SUM is now 19)
PLAYER 2: +2 (SUM is now 21)


(It's worth thinking about a simplified version of the game, where the only move allowed is +1. In that case, it's clear that the player who starts will win, because they reach the odd sums and their opponent reacher the even sums. Refer back to is_even() and think about this after you've understood the code below.)

We'd like to write a function that determines whether reaching a certain sum results in a win (assuming optimal play.) Obviously, the function should return True for 21 -- if you reach 21 first, you win.

The function should return False for 19 and 20, since if you reach 19 or 20 first, your opponent can make a move to get to 21.

The function should return True for 18. That because if you reach 18 first, your opponent can only get to 19 or 20. That means you win.

Let's try to encode this logic in code

In [1]:
GOAL_SUM = 21
MOVES = [1, 2]

def is_winning_sum(s):
    '''Return True if reaching the sum s leads to winning the game'''
    if s == GOAL_SUM:
        return True
        
    #Try every possibly move for your opponent. If any move by the
    #opponent leads to a winning sum, return False. If NO moves
    #by the opponent result in a winning sum, return True
    for move in MOVES:
        if is_winning_sum(s+move):
            return False
    return True

This is the usual trick with recursion: we assume the function works, and then call it as a helper function. Because eventually the call tree will get to 21, and the return value for is_winning_sum(21) is the correct one, the function works.

In [2]:
is_winning_sum(21)
Out[2]:
True
In [3]:
is_winning_sum(20)
Out[3]:
False
In [4]:
is_winning_sum(19)
Out[4]:
False
In [5]:
is_winning_sum(18)
Out[5]:
True
In [6]:
is_winning_sum(17)
Out[6]:
False
In [7]:
is_winning_sum(16)
Out[7]:
False
In [8]:
is_winning_sum(15)
Out[8]:
True

Does the first player win with optimal play? We can find out by checking whether getting to 0 mean you win or lose (the second player can be taken to reach 0 first.)

In [9]:
is_winning_sum(0)
Out[9]:
True

The second player wins with optimal play. (A bit of thought will reveal that here, sums divisible by 3 lead to a guranteed win with optimal play.)

Let's now write a function to return the best computer move:

In [10]:
def computer_move(cur_sum):
    for move in MOVES:
        if is_winning_sum(cur_sum+move):
            return move
    return MOVES[int(random.random()*len(MOVES))]

Here, the last line returns a random move (since int(random.random()*len(MOVES)) is either 0 or 1.) This is the "go crazy if it looks like you're losing" strategy -- not an uncommon bug in AIs. Arguably, the better thing to do is to return min(MOVES) -- that ensures that your defeat is further way than it might have been.

Let's try to trave is_winning_sum(17)

    is_winning_sum(21)
 True  \       /\ 
        \       \            is_winning_sum(21)            is_winning_sum(21)    
        is_winning_sum(20)    /\  /                          /\  \
      False    /\             /  / True                       \   \True
           \    \            / /                               \   \
            is_winning_sum(19)                              is_winning_sum(20)    
           False \      /\            __________________________/\     |
                  \      \           /                                 |False
                  is_winning_sum(18)------------------------------------
                True  \       /\           
                       \      |       
                       is_winning_sum(17) ------ False





Note that we only produce to calls inside is_winning_sum(n) if is_winning_sum(n+1) is False, because if it's True, we return False from is_winning_sum(n) straight away (if we know that the opponent can win by going +1, there is no point in checking whether the opponent will win or lose if they play +2.)