Learning Python By Example #2: Bagels

My Intro

I'm improving my Python skills by coding through The Big Book of Small Python Projects by Al Sweigart. I've written a few Python scripts in the past, but never went very deep and had big gaps of time between uses.

In 1981 style, I'm manually typing in the code from the book. In 2021 style, I'm blogging about each program as I go to help me reinforce the learning even more and hopefully support others on the same journey.

Here's a direct link to read All Sweigart's "Bagels" code, though I suggest you buy the book if you're finding the code useful.

Note: Sorry for the line number references in the text below. My blog lets me start and end them as I need to, but Dev.To... not so much it seems.

Don't worry, this game is gluten free

"Bagels," in this instance, is a guessing game, not a gluten-laden ring of chewy goodness. I have actually baked home-made bagels a few times, but that's another story for another time.

As I started the process of typing this in, I realized something...

I didn't want to type in his intro comments that didn't contain anything useful about programming. So just as I wouldn't have typed that stuff in out of Compute, I am not now. But out of respect, since I'm publishing my version (instead of just writing it to a single-sided 5.25" floppy), I'm allowing myself to cut and paste that part..

Strolling through Al's intro

# Adding Al's header
# His version and my subsequent version of it are
# CC-BY-NC-SA - https://creativecommons.org/licenses/by-nc-sa/4.0/
"""Bagels, by Al Sweigart [email protected]
A deductive logic game where you must guess a number based on clues.
View this code at https://nostarch.com/big-book-small-python-projects
A version of this game is featured in the book "Invent Your Own
Computer Games with Python" https://nostarch.com/inventwithpython
Tags: short, game, puzzle"""

import random

NUM_DIGITS = 3
MAX_GUESSES = 10

def main():
  print('''Bagels, a deductive logic game.
By Al Sweigart [email protected]

I am thinking of a {}-digit number with no repeated digits
Try to guess what it is. Here are some clues:
When I say:      That means:
  Pico           One digit is correct but in the wrong position.
  Fermi          One digit is correct and in the right position
  Bagels         No digit is correct.

For example, if the secret number was 248 and your guess was 843, the
clues would be Fermi Pico.'''.format(NUM_DIGITS))


# If the program is run (instead of imported), run the game:
if __name__ == '__main__':
  main()

Above are the first 26 and last 3 lines of Al's script. It's collapsed so you can look at the code or leave it minimized.

You might be asking "why the last three?" Because those are the ones that actually start the game loop when you python programname.py on the command-line. My process for authoring is to write to a point where I can run the code and it should work, then test and make sure it works before I move on.

One thing I learned from copying in programs from magazines was to test early and test often. So I added those lines to be able to test the printout of those instructions.

Intro Notes

As I added the intro, something stood out.

I'm used to percent-prefixed letters like %s or %d in JavaScript string formatting. Seeing him use {} on line 18 of his code (20 of mine) made me go look this up to understand how and why he used it. I found an article on Python string formatting that helped me understand the how and why of using curly braces. I have a feeling I'll revisit that article and get a deeper understanding of string formatting a few times before I'm done with this book.

The game loop

while True:
    #This stores the secret number the player needs to guess:
    secretNum = getSecretNum()
    print('I have thought up a number.');
    print(' You have {} guesses to get it.'.format(MAX_GUESSES))

    numGuesses = 1
    while numGuesses <= MAX_GUESSES:
      guess = ''
      #Keep looping until they enter a valid guess
      #Greg's Note: "valid" = 3 digits
      while len(guess) != NUM_DIGITS or not guess.isdecimal():
        print('Guess #{}: '.format(numGuesses))
        guess = input('> ')

      clues = getClues(guess, secretNum);
      print(clues)
      numGuesses += 1

      if guess == secretNum:
        break # they're correct, so break the loop
        # Greg's note: Congrats come in the getClues function
      if numGuesses > MAX_GUESSES:
        print('You ran out of guesses.')
        print('The answer was {}'.format(secretNum))

    # Ask player if they want to play again
    print('Do you want to play again? (yes or no)')
    if not input('> ').lower().startswith('y'):
      break
  print('Thanks for playing!')

Above is the Game Loop, lines 28 - 56 of Al's code, lines 30-?? of mine. Yes, I know the last sample ended at 33, but it also included the final three lines of the whole game, so the code above goes above those.

Game Loop Notes

Reminder: Python has case-sensitive booleans

What was important for me was the reminder that the T in True (a boolean literal) is capitalized in Python, though it isn't in a number of other languages. That caught me up the first few times I tried writing Python. Nice to get an early reminder of it.

Coding conventions prevent bugs

While writing line 34, I accidentally typed numGuesses from line 36 as the variable in the string formatting. But something looked wrong to me. At this point A) numGuesses hadn't been defined and B) the number used there should have been a constant. Since a constant is usually defined in ALL CAPS.

Noticing that caused me to check my code against Al's reference and catch my error moments after I made it (instead of catching it at run time).

Autocompletion FTW

Back when I was copying code from magazines, home PCs didn't have IDEs or text editors with autocompletion; at least not in the Commodore, Tandy, or Apple II systems I got to use. Autocomplete makes this a less arduous process.

This won't run yet

Although the instructions ran without issue, now that the game loop has been added, the code won't run because it calls functions that have not yet been defined. The last part of this will be to define them and then I can test it.

Helper functions... aaand scene.

def getSecretNum():
  # Returns a string made up of NUM_DIGITS unique random digits
  numbers = list('0123456789') #create a list of digits from 0-9
  random.shuffle(numbers) #shuffle them into random order

  # Get the first NUM_DIGITS in the list for the secret number
  secretNum = ''
  for i in range(NUM_DIGITS):
    secretNum += str(numbers[i]);
  return secretNum

def getClues(guess, secretNum):
  #Returns a string with the clues based on the guess
  if guess == secretNum:
    return 'You got it!'

  clues = []

  for i in range(len(guess)):
    if guess[i] == secretNum[i]:
      clues.append('Fermi')
    elif guess[i] in secretNum:
      clues.append('Pico')
  if len(clues) == 0:
    return "Bagels"
  else:
    # sort the clues into alpha order so their position doesn't give info away
    clues.sort()
    # join the clues into a single string
    return ' '.join(clues)


# If the program is run (instead of imported), run the game:
if __name__ == '__main__':
  main()

Above are the helper functions that get called by the game loop, lines 59 - 98 of Al's code, 62-96 of mine. The final three lines of the game reappear.

These functions power the game's important features... picking a secret number and checking your answers against them. Once these are added, I can test my code and play the game.

Helper Functions Notes

Where should you import packages?

One thing that immediately stood out to me was you don't use random anywhere else except the getSecretNum() function. I wondered if it might be better to import it at the top (where it is imported) or in the function.

Apparently it gets cached on the first import so there's no major performance hit if the function runs again. Therefore, for illustrative purposes, you could import it here.

Best practices dictate importing it at the top of the script (as in most languages) so you can see everything you're importing at the very beginning and all packages are globally available. However, that StackOverflow question I linked to did make an interesting point in one of the comments. If this function were not a core function, but only got called in specific instances, that might cause an exception to the rule.

For example if you're running the script on-demand with a service like AWS Lambda, this package is needed only for a function that gets invoked less than 1% of the times it's run, and you're getting billed by execution time and memory used, you might want to only import the package inside that function to improve speed and cost.

Why loop when you can slice?

Lines 69-70 bugged me. I had it in my head that numbers was like a string, and thus wondered why we were iterating through it to create the secret number rather than just lopping off the bit we needed.

But it's not a string. It's an array ("list") of digits that needs to be joined and turned into a string, which could be more compute cycles than the loop which doesn't need to join it and only converts the digits being used.

Wrapping it all up

When all was said and done, I only had two transcription errors. I left off a parentheses after lower in line 58 of the Game Loop section and used square brackets instead of parentheses around NUM_DIGITS on line 69 here in the Helper Functions.

This took longer than I expected for two reasons... 1) I followed butterflies fairly often (for example: looking up information on when to import), and 2) spent some time correcting my mistaken assumptions like a number of questions I had around the numbers list because I was mistaken about its datatype. I didn't blog most of them because they made no sense if it wasn't a string.

Well, knowing just enough to get into trouble got me this far. I'm looking forward to project #2. I'd originally hoped to post these daily, but now it feels like that's overreaching. Perhaps a twice a week or so frequency will be better.

Want to read the next one?

I'll link it in this last section when it drops. But you can also follow me on Twitter or LinkedIn. I may also start livecoding these on Twitch/YouTube/LinkedIn (that's right, baby... simulcasting). Would you watch? Leave a comment below.

Code Output

K:\MyProjects\PyProj>python 01-bagels.py
Bagels, a deductive logic game.
By Al Sweigart [email protected]

I am thinking of a 3-digit number with no repeated digits
Try to guess what it is. Here are some clues:
When I say:      That means:
  Pico           One digit is correct but in the wrong position.
  Fermi          One digit is correct and in the right position
  Bagels         No digit is correct.

For example, if the secret number was 248 and your guess was 843, the
clues would be Fermi Pico.
I have thought up a number.
 You have 10 guesses to get it.
Guess #1:
> 123
Pico
Guess #2:
> 345
Bagels
Guess #3:
> 678
Pico Pico
Guess #4:
> 278
Pico
Guess #5:
> 168
Pico Pico
Guess #6:
> 167
Pico Pico Pico
Guess #7:
> 716
You got it!
Do you want to play again? (yes or no)
> n
Thanks for playing!

19