Hangman

2023-06-26 python hangman random unicodedata rich game fun

In order to try out python a bit more, I am trying to write some simple programs. One thing I worked on recently is an implementation of classic Hangman game for guessing words. I did some preparation that I mentioned in previous post on creating dictionary file.

Now there are number a problems I needed to address:

  1. Load the list of words
  2. Randomly pick one for the game
  3. The Czech words contain accents that I don’t want to take into account when comparing
  4. Visualize current state (show remaining attempts, the already guessed letters, already tried letters and the hangman picture)
  5. Determine success/fail of the guess
  6. Check winning/losing conditions and communicate that to the user

Going step by step, here is my approach to each one.

Loading of the list is quite easy, simple function can load the whole file and return it in array. Only complication was to load the file as unicode (utf-8) and strip terminating newlines.

def get_words(input_file):
    with open(input_file, encoding='utf-8') as file:
        return [line.rstrip() for line in file]

For random picking of one of words, there is handy module random and its random.choice. In terms of code this is something like

import random

words = get_words('hangman-words.txt')
word_to_guess = random.choice(words)

For comparison, the module unicodedata provides means to encode any unicode string into ascii. Let’s define a function to turn a string into uppercase form without any accents

import unicodedata

def upper_ascii(s):
    return unicodedata.normalize('NFKD', s.upper()).encode('ASCII', 'ignore')

print(upper_ascii('žluťoučký'))     # b'ZLUTOUCKY'

For simple console-based interface I used rich library and its method console.clear, console.print, Panel, and console.input. The interface would look like this

╭────── HANGMAN ───────╮
│ Remaining 2 attempts │
│                      │
│     ┌┬┬┬┬┬┬┬┐        │
│    ┌┼┼┴┴┴┴┴┴┴┐       │
│    ├┼┘       │       │
│    ├┤       ╭┴╮      │
│    ├┤       ╰┬╯      │
│    ├┤      ┌─┼─┐     │
│    ├┤      │ │ │     │
│    ├┤        │       │
│    ├┤                │
│    ├┤                │
│    ├┤                │
│    ├┤                │
│ ═══╧╧═════════════   │
│                      │
│ Word to guess:       │
│ - - D - - Č E K      │
╰──────────────────────╯

Already tried: A B F G H
Guess letter (? = help):

For game state I defined few variables in main loop:

  • to_guess is array of tuples, each having a letter and whether it was guessed
  • remaining_attempts goes from starting number down to zero
  • tries is simple array of already tried letters

Manipulation of to_guess is easy with list comprehensions that I grown to like. For instance

  • print already guessed letters and keep others replaced with dash
    ' '.join([c if visible else '-'  for c,visible in to_guess]
    
  • count number of visible/guessed letters
    visible = sum(visible for _,visible in to_guess)
    
  • build new to_guess based on input in guess variable
    to_guess = [(c, visible or upper_ascii(guess) == upper_ascii(c)) for c,visible in to_guess]
    

Success of the guess can be determined by checking of whether number of visible letters changed during to_guess array update. Based on this information, we can alter tries array and remaining_attempts counter. We win when all items in to_guess are visible and we lose when remaining_attempts goes down to zero.

When we put all this together, the result is something like this

import unicodedata
import random
from rich.console import Console
from rich.panel import Panel

def get_words(input_file):
    with open(input_file, encoding='utf-8') as file:
        return [line.rstrip() for line in file]

def upper_ascii(s):
    return unicodedata.normalize('NFKD', s.upper()).encode('ASCII', 'ignore')

def show_entry(console, guess, remaining_attempts):
    console.clear()
    console.print(Panel(
        f'Remaining {str(remaining_attempts)} attempts\n'
      + get_hangman_picture(remaining_attempts) + '\n'
      + 'Word to guess:\n'
      + ' '.join([c if visible else '-'  for c,visible in guess]
    ), expand=False, title='HANGMAN'))
    console.print()

def play(console, word):
    to_guess = [(c,False) for c in word.upper()]
    tries = []
    remaining_attempts = 7
    while remaining_attempts > 0:
        show_entry(console, to_guess, remaining_attempts)
        if len(tries) > 0:
            console.print('Already tried: ' + ' '.join(tries))
        guess = console.input(f'Guess letter (? = help): ')

        if guess == '?':
            guess = random.choice([c for c, visible in to_guess if visible == False])

        visible_before = sum(visible for _,visible in to_guess)
        to_guess = [(c, visible or upper_ascii(guess) == upper_ascii(c)) for c,visible in to_guess]
        visible_after = sum(visible for _,visible in to_guess)

        if visible_after == len(to_guess):
            show_entry(console, to_guess, remaining_attempts)
            print('You won')
            break

        if visible_after == visible_before:
            tries.append(guess)
            remaining_attempts -= 1
    else:
        show_entry(console, to_guess, remaining_attempts)
        word = ' '.join([c for c,_ in to_guess])
        print(f'You lost, the word was {word}')

def main():
    words = get_words('hangman-words.txt')
    console = Console()

    while True:
        play(console, random.choice(words))
        if input("Play Again? (Y/N) ").upper() == "N":
            break

def get_hangman_picture(remaining_attempts):
    return [
"""
    ┌┬┬┬┬┬┬┬┐
   ┌┼┼┴┴┴┴┴┴┴┐
   ├┼┘       │
   ├┤       ╭┴╮
   ├┤       ╰┬╯
   ├┤      ┌─┼─┐
   ├┤      │ │ │
   ├┤        │
   ├┤       ┌┴┐
   ├┤       │ │
   ├┤      ─┘ └─
   ├┤
═══╧╧═════════════
""",
"""
    ┌┬┬┬┬┬┬┬┐
   ┌┼┼┴┴┴┴┴┴┴┐
   ├┼┘       │
   ├┤       ╭┴╮
   ├┤       ╰┬╯
   ├┤      ┌─┼─┐
   ├┤      │ │ │
   ├┤        │
   ├┤       ┌┘
   ├┤       │
   ├┤      ─┘
   ├┤
═══╧╧═════════════
""",
"""
    ┌┬┬┬┬┬┬┬┐
   ┌┼┼┴┴┴┴┴┴┴┐
   ├┼┘       │
   ├┤       ╭┴╮
   ├┤       ╰┬╯
   ├┤      ┌─┼─┐
   ├┤      │ │ │
   ├┤        │
   ├┤
   ├┤
   ├┤
   ├┤
═══╧╧═════════════
""",
"""
    ┌┬┬┬┬┬┬┬┐
   ┌┼┼┴┴┴┴┴┴┴┐
   ├┼┘       │
   ├┤       ╭┴╮
   ├┤       ╰┬╯
   ├┤      ┌─┘
   ├┤      │ 
   ├┤
   ├┤
   ├┤
   ├┤
   ├┤
═══╧╧═════════════
""",
"""
    ┌┬┬┬┬┬┬┬┐
   ┌┼┼┴┴┴┴┴┴┴┐
   ├┼┘       │
   ├┤       ╭┴╮
   ├┤       ╰─╯
   ├┤
   ├┤
   ├┤
   ├┤
   ├┤
   ├┤
   ├┤
═══╧╧═════════════
""",
"""
    ┌┬┬┬┬┬┬┬┐
   ┌┼┼┴┴┴┴┴┴┘
   ├┼┘
   ├┤
   ├┤
   ├┤
   ├┤
   ├┤
   ├┤
   ├┤
   ├┤
   ├┤
═══╧╧═════════════
""",
"""

   ┌┐
   ├┤
   ├┤
   ├┤
   ├┤
   ├┤
   ├┤
   ├┤
   ├┤
   ├┤
   ├┤
═══╧╧═════════════
""",
"""












══════════════════
""",
    ][remaining_attempts]

if __name__ == "__main__":
    main()

I liked the exercise, it was quite some learning of new concepts. Obviously it would be better to use a little better data structures, but I will need to learn more on how to build classes/objects in python and use it in my code. This will be for next time.