2
\$\begingroup\$

I made the hangman game using OOP. It is the first "big" project I do using OOP. I would like to know if there is something to improve in the program I made.

This is the code:

#!/usr/bin/env python3
# Hangman game
import random


class Hangman:
    HANGMAN_PICS = (r'''
        ┌───┐
        │   │
            │
            │
            │
            │
    ════════╛''', r'''
        ┌───┐
        │   │
        O   │
            │
            │
            │
    ════════╛''', r'''
        ┌───┐
        │   │
        O   │
        |   │
            │
            │
    ════════╛''', r'''
        ┌───┐
        │   │
        O   │
       /│   │
            │
            │
    ════════╛''', r'''
        ┌───┐
        │   │
        O   │
       /│\  │
            │
            │
    ════════╛''', r'''
        ┌───┐
        │   │
        O   │
       /│\  │
       /    │
            │
    ════════╛''', r'''
        ┌───┐
        │   │
        O   │
       /│\  │
       / \  │
            │
    ════════╛''')


    def __init__(self, word):
        """
         Our constructor class that instantiates the game.
        """
        self.max_trials = len(self.HANGMAN_PICS) - 1
        self.trials = self.max_trials
        self.secret_word = word
        self.secret_word_low = word.lower()
        self.blanks = list('_' * len(self.secret_word))
        self.found_letters = set() 


    def _spacer(self, length=50):
        """
         Returns a dash string to be used as a separator.
        """
        return '-' * length


    @property
    def _blanks_string(self):
        """
         Returns a string with blanks.
        """
        return ''.join(self.blanks)


    def _draw_hangman(self):
        """
         Returns a string with the current position of the hangman.
        """
        return f'{self.HANGMAN_PICS[self.max_trials-self.trials]}\n'

    
    def _guess_letter(self, letter):
        """
         Replaces the guessed letter in the blanks and adds the
         letter to a set containing all the letters used so far.
        """
        for i, char in enumerate(self.secret_word_low):
            if char == letter:
                self.blanks[i] = letter
                self.found_letters.add(letter)


    def _valid_input(self, letter):
        """
         Checks if the string entered by the user has a length of 1
         and if all characters in the string are in the alphabet.
        """
        return len(letter) != 1 or not letter.isalpha()


    def _win(self):
        """
         Asigns a boolean value of True if the user guess the secret word.
        """
        return self._blanks_string == self.secret_word_low


    def _play(self):
        """
         The game stage.
        """
        print(f'\nSecret word: {self._blanks_string}')
        print(self._draw_hangman())
        print(self._spacer())

        while self.trials > 0 and not self._win():
            letter = input('\nEnter a letter: ').lower()

            if self._valid_input(letter):
                print('The value entered is invalid.\n')
            elif letter in self.found_letters:
                print('This letter has already been entered. Enter another letter.\n')
            elif letter in self.secret_word_low:
                self.found_letters.add(letter)
                self._guess_letter(letter)
                print(self._blanks_string)
            else:
                self.found_letters.add(letter)
                self.trials -= 1
                print(f'\nYou missed and lost a life. You have {self.trials} trials left.')
                print(self._draw_hangman())
            
            print(self._spacer())
        
        if self._win():
            print(f'\nYou won. The secret word is: {self.secret_word}.')
        else:
            print(f'\nYou lost. The secret word is: {self.secret_word}.')
            

def main():
    """
     Selects a word from a list of words.
     This word is to be guessed in the game.
    """
    with open('words.txt', 'r+', encoding='utf-8') as words:
        secret_word = random.choice(words.read().split())
        secret_word = ''.join(secret_word)
    game = Hangman(secret_word)
    game._play()


if __name__ == '__main__':
    main()
```
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

Gameplay

Secret word: _____

How many letters are in this secret word? I'd recommend telling the player rather than them guessing, or needing to do something like highlighting the underscores one by one.

You could tell the player explicitly, or make it easier to count the letters. Whatever you prefer.

Secret word (5 letters): _ _ _ _ _

How many guesses does a player start with? The player isn't told 'trials' until they miss with a guess.


Naming things is hard

In Python, a leading underscore in front of a variable name has a specific meaning. It is a marker that the method is private. Marking a method as private means anything outside of the class should not use it.

Hangman._play looks like it should not be private. As it is intended to be used outside of the Hangman class, it should public. The main method has an example of using _play outside of the class.

def main():
    ...
    game = Hangman(secret_word)
    game._play()

_play can be renamed to just play.

This applies to any method or attribute in the class. If it is meant to be used outside of the class, don't mark it as private.


def _valid_input(self, letter):
    """
     Checks if the string entered by the user has a length of 1
     and if all characters in the string are in the alphabet.
    """
    return len(letter) != 1 or not letter.isalpha()

valid_input will return a boolean. Does True mean the input is valid? Or does True mean the input had a problem, and then False means the input is valid? Try to make sure the most simple interpretation of the name matches what the function will do.

In this case, True means the input was invalid, which is the opposite of what I would have expected. You have the choice of changing the name, or inverting the logic.

I think inverting the logic will make the whole function more simple to read and understand. As a second tweak, I would include the prefix is as an indicator that the method will return a boolean value.

def is_valid_input(self, letter):
    """
    Return True if the input is a single letter in the alphabet.
    """
    return len(letter) == 1 and letter.isalpha()

This will need a change wherever the method is used.


def has_won(self):
    """
     Asigns a boolean value of True if the user guess the secret word.
    """
    return self._blanks_string == self.secret_word_low

This is ok, but a little confusing at first. When would a blank string be the same as the secret word unless the secret word was blank? I missed the 's' at the end of 'blanks'.

Perhaps a better name than blanks_string is blanks? I don't feel all that fondly about it though.

I would instead suggest implementing this a different way. You could count the number of correct letters so far. You might check that there are no blanks left in the blanks_string, which would mean all the letters have been guessed.


Small restructure

There is a lot going on in the main loop of the play method.

if not self.is_valid_input(letter):
    print('The value entered is invalid.\n')
elif letter in self.found_letters:
    print('This letter has already been entered. Enter another letter.\n')
elif letter in self.secret_word_low:
    self.found_letters.add(letter)
    self.guess_letter(letter)
    print(self._blanks_string)
else:
    self.found_letters.add(letter)
    self.trials -= 1
    print(f'\nYou missed and lost a life. You have {self.trials} trials left.')
    print(self.draw_hangman())

I would try and split the logic to better show invalid vs valid and correct vs incorrect.

if not self.is_valid(letter):
    print('The value entered is invalid.\n')
    continue

if letter in self.found_letters:
    print('This letter has already been entered. Enter another letter.\n')
    continue

if letter in self.secret_word_low:
    self.found_letters.add(letter)
    self.guess_letter(letter)
    print(self._blanks_string)
else:
    self.found_letters.add(letter)
    self.trials -= 1
    print(f'\nYou missed and lost a life. You have {self.trials} trials left.')
    print(self.draw_hangman())

Based on this, it looks like the name found_letters is not quite right. It also tracks guessed but incorrect letters. I think used_letters is a better name.

def guess_letter(self, letter):
    """
     Replaces the guessed letter in the blanks and adds the
     letter to a set containing all the letters used so far.
    """
    for i, char in enumerate(self.secret_word_low):
        if char == letter:
            self.blanks[i] = letter
            self.found_letters.add(letter)

It looks like the method guess_letter doesn't actual check the guess, it updates the blanks (if we already knew the letter was in the secret word). It also does extra work, as both guess_letter and play marked a letter as used.

I think it would be worth making guess_letter do more of the work. It should track if the letter was correct alongside marking where the letter appears in the blanks. I don't think guess_letter should care about a repeated guess, as the guess letter is already checked beforehand. Incidentally, a repeated guess won't waste a trial, so it shouldn't matter to someone playing the game.

def guess_letter(self, letter):
    """
    Replace the guessed letter in the blanks where it matches in the secret word and
    return True if that letter appeared in the secret word.
    """
    good_guess = False
    for i, char in enumerate(self.secret_word_low):
        if char == letter:
            good_guess = True
            self.blanks[i] = letter
    return good_guess

And the loop contents would be something like:

        if not self.is_valid(letter):
            print('The value entered is invalid.\n')
            continue

        if letter in self.used_letters:
            print('This letter has already been entered. Enter another letter.\n')
            continue

        guess_was_good = self.guess_letter(letter)
        self.used_letters.add(letter)

        if guess_was_good:
            print(self._blanks_string)
        else:
            self.trials -= 1
            print(f'\nYou missed and lost a life. You have {self.trials} trials left.')
            print(self.draw_hangman())

Polish

There are a few small details to look at. They won't make or break the experience, but are nice to fix.

The first time I ran the code, I got an error message: FileNotFoundError: [Errno 2] No such file or directory: 'words.txt'. Don't forget to include first time instructions, even if they are just a rough list of bullet points.

After creating an empty file called words.txt I got the error message: IndexError: Cannot choose from an empty sequence. For someone looking to play the game, this doesn't mean much. Could the error message tell the player what went wrong? Even better, could it say what to try if this happens?

You missed and lost a life. You have 1 trials left.

Should be "1 trial left."

You lost. The secret word is: ...

You won. The secret word is: ...

Should be "The secret word was: ..."

\$\endgroup\$
2
  • \$\begingroup\$ Thank you very much for the advice, as for the documentation you see it well? \$\endgroup\$ Commented Feb 7, 2022 at 18:42
  • \$\begingroup\$ @LucioMazzini If you are referring to the docstrings for each method, yes, good job! \$\endgroup\$
    – spyr03
    Commented Feb 8, 2022 at 15:59

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.