Hangman implemented in 3 lines of Python (2)

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

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 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 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 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:

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.