Hangman implemented in 3 lines of Python (2)

Just because you can, doesn't mean you shrdlu.

by on

Yesterday, I was reminded of the /usr/share/dict/words file during a conversation I had at lunch. This file is simply a collection of valid words, and is present on all UNIX and UNIX-like systems. It can be a pretty useful resource for solving crossword puzzles or word jumbles.

I had half an hour left for lunch, and so I thought about doing something cool with it. Without thinking too hard, I came up with the idea of implementing Hangman in Python. My first implementation was around a dozen lines of readable code, but already I could see how concise Python allowed me to be, and so I resolved to revisit the problem the next day from a code-golfer's point of view, and attempt to solve it in as few lines as possible (without, of course, the trivial trick of concatenating all my lines with ;). The restriction I set for myself was that each line must be just one Python statement or expression (although my solution does stretch that somewhat with tuple-packing/unpacking. I also allow myself to go over 80 characters a line and ignore PEP-8 in other ways.

The number of bytes of this code could be further shrunk by limiting the size of variable identifiers, eliminating non-significant whitespace, and using generator comprehensions where I've used filter() and map(). I feel that would obscure what's going on here too much. While not a paragon of readability, the variable names give readers a clue into what I, the author, intended. Stripped of those, the code crosses the line from acute brevity over into obfuscation.

The solution itself

Here it is, in all it's g(l)ory. You may need to scroll a bit.

license, chosen_word, guesses, scaffold, man, guesses_left = 'https://opensource.org/licenses/MIT', ''.join(filter(str.isalpha, __import__('random').choice(open('/usr/share/dict/words').readlines()).upper())), set(), '|======\n|   |\n| {3} {0} {5}\n|  {2}{1}{4}\n|  {6} {7}\n|  {8} {9}\n|', list('OT-\\-//\\||'), 10
while not all(letter in guesses for letter in chosen_word) and guesses_left: _, guesses_left = map(guesses.add, filter(str.isalpha, raw_input('%s(%s guesses left)\n%s\n%s:' % (','.join(sorted(guesses)), guesses_left, scaffold.format(*(man[:10-guesses_left] + [' '] * guesses_left)), ' '.join(letter if letter in guesses else '_' for letter in chosen_word))).upper())), max((10 - len(guesses - set(chosen_word))), 0)
print 'You', ['lose!\n' + scaffold.format(*man), 'win!'][bool(guesses_left)], '\nWord was', chosen_word

Weighing in at 3 lines of Python code, this is a pretty compact implementation. If you want to run it at your own terminal to play it yourself on a UNIX-like machine, you can download it from this gist, or copy and paste the 3 lines above into hangman.py and run it. You should notice the following features:

  • A word is chosen randomly from the system dictionary. Of course, this dictionary includes proper names. The word list can be easily changed.
  • Keeping track of the number of (wrong) guesses left. Successful guesses do not add to the hangman, and repeated missed guesses do not add to the hangman.
  • Positional blanks hint at the hidden word, that are filled in with correct guesses.
  • An ASCII Hangman built as you continue to guess wrongly.
  • Multiple guesses are allowed, and non-alphabet character guesses are ignored.

A line-by-line analysis

To understand what's going on here, I'm going to take you through each line of the code and explain how the magic happens.

Line the first sets up the variables that we are going to use in the rest of the code. While good style would probably have each assignment on a different line, we can make use of Python's multiple assignment to do it all on one.

license  = 'https://opensource.org/licenses/MIT'
chosen_word = ''.join(
    filter(
      str.isalpha,
      __import__('random').choice(
        open('/usr/share/dict/words').readlines()
        ).upper()))
guesses = set()
scaffold = '|======\n|   |\n| {3} {0} {5}\n|  {2}{1}{4}\n|  {6} {7}\n|  {8} {9}\n|'
man = list('QT-\\-//\\||')
guesses_left = 10
  • The license of this little snippet is the MIT License
  • chosen_word gets assigned a random.choice() from a list of words created by reading from the words file. Because the words file may include internal punctuation, e.g. a word "zucchini's", we filter each character through isalpha to strip that and trailing newlines, and then join the resulting list back to make a string. Note that by using the builtin __import__ function to import random, we are able to save the line we would have spent on the import
  • guesses is assigned a set, that is going to hold the uppercase letters that the player has guessed.
  • scaffold is a template string that draws a scaffold, with template slots defined for one-character body parts of the hangman to go into.
  • man is a 10-character string of ASCII body parts for our hangman.
  • guesses_left is going to be the number of guesses that the player has left. This starts off at 10.

The second line contains the main game loop. Since Python permits (without recommending) you to put a single-statement body on the same line as the compound statement, we take advantage of that to fold everything onto one line. Let's treat them separately here.

while not all(letter in guesses for letter in chosen_word) and guesses_left:

The condition of the while loop accounts for the two ways to end a Hangman game. You either successfully guess the word, or you run out of guesses. We check if the player has successfully guessed the word by testing to see if all letters in the word are in the set of guesses. We check to see if the user has lost by ensuring that there are still some guesses left. If the user has not guessed all the letters, and the user still has some guesses left, we must continue playing.

The next part takes advantage of the fact that Python orders the evaluation of tuple packing to condense two lines into one. To reason about it, let's pretend we've replaced the two major parts with meaningful function names as stand-ins. We can read it as:

 _, guesses_left = process_player_guesses(), calculate_new_guesses_left()

Which saves a line over:

process_player_guesses()
guesses_left = calculate_new_guesses_left()

but has the same effect. How does the mythical process_player_guesses() work?

map(guesses.add,
  filter(
    str.isalpha,
    raw_input('%s(%s guesses left)\n%s\n%s:' % (
      ','.join(sorted(guesses)),
      guesses_left,
      scaffold.format(*(man[:10-guesses_left] + [' '] * guesses_left)),
      ' '.join(letter if letter in guesses else '_' for letter in chosen_word))
      ).upper()))

A lot of the heavy lifting here is done by raw_input(), as it allows us to both print out a prompt and get input from the user. The prompt we print out is composed of:

  • A comma separated list of the users guesses so far, sorted.
  • The number of guesses the user has left.
  • The scaffold template string, which we format with
  • As many pieces of the man as the user has had wrong answers (man[:10-guesses_left]) plus
  • Enough empty spaces to fill out the remaining empty slots in the template string.

We take the resulting string that the player entered and run it through a filter to reject non-alphabet characters. We then convert whatever remains to uppercase, and add that, one by one, to our set of guesses using the map function.

Let's come back to the mythical calculate_new_guesses_left()

max((10 - len(guesses - set(chosen_word))), 0)

After the addition of the new guesses has run, the number of guesses left is equal to ten minus the number of wrong guesses, where the number of wrong guesses is defined as all the letters that are in the guesses set, but not in the set of letters of the chosen word. This number may go into the negative, because a user may have entered all 26 alphabets on one line, for example. We certainly don't want to reward such behaviour.

The main game loop will continue until one of the terminating conditions have been achieved. At that point, we determine whether the user has won or lost, and regardless, tell them what the word was.

print 'You', ['lose!\n' + scaffold.format(*man), 'win!'][bool(guesses_left)],
print '\nWord was', chosen_word

We determine whether the user won by taking the bool of the number of guesses left. This lets us detect cheaters who might successfully guess all the letters in the word by entering the entire alphabet. Any winner should have at least one guess left. In Python, False has the same value as the integer 0, and True has the same value as 1, so we can use this to index into a list of possible messages to pick the message we should print out.

Since the main game loop never gets a chance to print out an entire hangman, we take this opportunity to include the ASCII art with the losing message, while the winning message is just kept simple.

Conclusion

So there you have it. Hangman, complete with ASCII art, in three lines of Python code.

To write out all the code and make minor refactorings took around 30 minutes. It took me way longer than that to write this blog post detailing exactly how it works. If I wanted to draw a moral from an exercise I entered into purely for amusement, I would have to repeat the oft-quoted wisdom that code that is readable and understandable is going to save you much more time in the long run.