Harvard CS50 - Introduction to Programming with Python
Jan 30th, 2024
Graduating as a mechanical engineer got me almost zero base knowledge needed from programming (except math and a bit of Matlab). During my years of professional coding, I picked up enormous amounts of knowledge related to computer science, but I occasionally still feel there is a lot of room for improvement.
That's why I decided that I will undergo Harvard's CS50 course. This is the first Harvard CS50 course I set myself to complete.
Each course has a final project, which my implementation of it I will present in these CS50-related blog posts.
Hangman - the CLI game
The game follows standard hangman game rules:
- You have ten chances to try to guess the word letter-by-letter
- You cannot repeat already guessed letters
- If you correctly guess the letter, it does not count towards your total of 10 tries
- If you reach ten failed tries, the game ends, and you lose
- If you guess the word before reaching ten tries, you win
The game has two modes:
- Normal mode | 5-letter random English word
- Hard mode | 6-letter random English word
Per standard hangman game rules, a part of the hangman is drawn for each unsuccessful try. If you reach ten failed attempts, a complete picture will be drawn.
Hangman - Start screen
Hangman - Start screen
After completing the game (winning or losing), you can start another game or quit the program.
Hangman - Lost game
Hangman - Lost game
Hangman - Mid game
Hangman - Mid game
Hangman - Won game
Hangman - Won game
Code structure
To play the game, clone the project, install Python, move into the repository's root folder, install dependencies with
pip install -r requirements.txt
and run
python project.py
inside the terminal.
project.py
- as per guidelines consists of a main function and three functions on the same level, needed for testing. However, for state-keeping reasons, I have initialized and kept all of my game inside a Game class, which is in the same file. I wanted to avoid using global variables.Como se indica en las pautas, el código consta de una función principal y tres funciones en el mismo nivel, necesarias para las pruebas. Sin embargo, por razones de mantenimiento de estado, he inicializado y mantenido todo mi juego dentro de una clase Game, que se encuentra en el mismo archivo. Quería evitar el uso de variables globales.
The three required functions on the same indentation level as the main function implement calls to Game class methods so that final project requirements are fulfilled. But I must point out that also all Class methods were thoroughly tested.
test_project.py
- contains all the tests of the project
requirements.txt
- contains all package dependencies and their versions used in the project
data/words.py
- contains two lists of five-letter and six-letter English words used when picking game modes
components/separators.py
- contains functions used to draw hangman game inside the CLI
components/stages.py
- contains stages of each of the wrong guess drawings for the hangman game. Stages are on purpose not following the DRY principle, so they are easier to maintain and review. If this was a bigger app, where performance would be critical, these stages + start screen drawings could be optimised.
Game class walkthrough
As already mentioned beforehand, class
Game
encapsulates the whole game logic.
Here is the breakdown of key methods and their properties:
- __init__: This method initializes game-related variables used both for rendering CLI game building blocks and for game logic.
def __init__(self):
self.separator_length = 40
self.game_screen = 0
self.game_mode = "0"
self.guessed_letters = []
self.used_letters = []
self.word = ""
self.failures = 0
self.already_used_letter = ""
project.py - Game class
- Getters and Setters: I've used these properties to both "prevent" direct manipulation with init initialized state variables and to control the way how these variables should be updated and retrieved.
@property
def game_screen(self):
return self._game_screen
@game_screen.setter
def game_screen(self, n):
self._game_screen = n
@property
def game_mode(self):
return self._game_mode
@game_mode.setter
def game_mode(self, n):
self._game_mode = n
@property
def guessed_letters(self):
return self._guessed_letters
@guessed_letters.setter
def guessed_letters(self, new_values):
if len(new_values) == 2 and 0 <= new_values[1] < len(self.guessed_letters):
self._guessed_letters[new_values[1]] = new_values[0]
elif len(new_values) == 1:
self._guessed_letters = new_values[0]
else:
self._guessed_letters = []
@property
def used_letters(self):
return self._used_letters
@used_letters.setter
def used_letters(self, new_values):
if len(new_values) == 2 and new_values[1]:
self._used_letters.append(new_values[0])
elif len(new_values) == 1:
self._used_letters = new_values[0]
else:
self._used_letters = []
@property
def word(self):
return self._word
@word.setter
def word(self, n):
if n == "1":
self._word = random.choice(words_five_letters).upper()
elif n == "2":
self._word = random.choice(words_six_letters).upper()
else:
self._word = n # for testing purposes
@property
def failures(self):
return self._failures
@failures.setter
def failures(self, n):
self._failures = n
@property
def already_used_letter(self):
return self._already_used_letter
@already_used_letter.setter
def already_used_letter(self, n):
self._already_used_letter = n
project.py - Game class
- run_game: This is how you start the game. It uses a while True loop and input functions to stop and wait on each of the loop's iterations. It controls the whole flow of the game logic by running other methods like start_game and main_game. It is also responsible for most of the state variable changes.
def run_game(self):
self.clear_terminal()
while self.game_screen <= 1:
if self.game_screen == 0:
self.start_game()
while self.game_mode != "1" and self.game_mode != "2":
user_input = input("Mode: ")
self.game_mode = user_input
self.word = user_input
self.guessed_letters = [[" " for _ in range(len(self.word))]]
self.game_screen = 1
elif self.game_screen == 1:
self.clear_terminal()
self.main_game()
if self.is_game_finished():
user_input = input("Decision: ").upper()
else:
user_input = input("Guess a letter: ").upper()
self.already_used_letter = ""
if len(user_input) == 1 and user_input.isalpha():
###
## check if hit, miss, or repeat guess
# hit
if user_input in self.word and user_input not in self.used_letters:
indexes_of_hit = [index for index, char in enumerate(self.word) if char == user_input]
for index in indexes_of_hit:
self.guessed_letters = [user_input, index]
# repeat
elif user_input in self.used_letters:
self.already_used_letter = user_input
# miss
else:
self.failures = self.failures + 1
###
# add to used letters list
if user_input not in self.used_letters:
self.used_letters = [user_input, True]
elif user_input == "YES":
self.restart()
elif user_input == "NO":
self.quit()
break
project.py - Game class
- start_game: Shows the initial CLI screen users see when starting the game. On this screen, the user is prompted to select the game difficulty. Based on the selection, run_game method sets a random word to guess and guides the user onto the main game screen. Otherwise, this method has no special logic, it is just responsible for game drawing.
def start_game(self):
draw_separator(self.separator_length)
draw_separator(self.separator_length)
self.empty_space()
self.empty_space()
self.empty_space()
center_text(text="HANGMAN", length=self.separator_length, draw_border=True, double_border=True)
self.empty_space()
self.empty_space()
self.empty_space()
draw_separator(self.separator_length)
draw_separator(self.separator_length)
self.empty_space()
center_text(text="Enter mode to start the game", length=self.separator_length, draw_border=True)
center_text(text="[1] - Normal mode", length=self.separator_length, draw_border=True)
center_text(text="[2] - Hard mode", length=self.separator_length, draw_border=True)
self.empty_space()
draw_separator(self.separator_length)
print("2023 - Alan Jereb".rjust(self.separator_length))
center_text(text="", length=self.separator_length)
project.py - Game class
- main_game: Show the main game CLI screen, which, through run_game tracks already used letters, correct user guesses, and draws stages of the hangman painting for each of the ten failed attempts. After ten failed attempts or successful game completion, the user is prompted for a game restart, which transfers the player back onto the initial screen or to terminate the game, which exits the program. Otherwise, this method has no special logic. It is just responsible for game drawing.
def main_game(self):
draw_separator(self.separator_length)
self.empty_space()
stage_functions = [stage.zero, stage.one, stage.two, stage.three, stage.four, stage.five, stage.six,
stage.seven, stage.eight, stage.nine, stage.ten]
if 0 <= self.failures <= 10:
stage_functions[self.failures](self.separator_length)
self.empty_space()
self.empty_space()
if self.user_has_won():
center_text(text="You win!", length=self.separator_length, draw_border=True, double_border=True)
elif self.user_has_failed():
center_text(text="You lose!", length=self.separator_length, draw_border=True, double_border=True)
self.empty_space()
if not self.user_has_failed():
center_text(text=(" ".join(self.guessed_letters)), length=self.separator_length, draw_border=True, double_border=True)
center_text(text=("__ " * len(self.word)), length=self.separator_length, draw_border=True, double_border=True)
else:
center_text(text=self.word, length=self.separator_length, draw_border=True, double_border=True)
self.empty_space()
draw_separator(self.separator_length)
if self.is_game_finished():
center_text(text="To play another game type: yes", length=self.separator_length, draw_border=True)
center_text(text="To quit the game type: no", length=self.separator_length, draw_border=True)
else:
center_text(text="Used letters:", length=self.separator_length, draw_border=True)
center_text(text=", ".join(self.used_letters[0:9]), length=self.separator_length, draw_border=True)
center_text(text=", ".join(self.used_letters[9:]), length=self.separator_length, draw_border=True)
draw_separator(self.separator_length)
if len(self.already_used_letter):
print("You have already used letter", self.already_used_letter ,"!")
else:
center_text(text="", length=self.separator_length)
center_text(text="", length=self.separator_length)
project.py - Game class
- clear_terminal: The method is used to clear all of the terminal contents and thus give an illusion of "animation" when different page stages are rerendered inside in main_game portion of the run_game
def clear_terminal(self):
if platform == "Windows":
os.system("cls")
else:
os.system("clear")
project.py - Game class
- restart: Method sets variables to the default values so that the run_game while loop puts the player back on to the start_game screen
def restart(self):
self.clear_terminal()
self.game_screen = 0
self.used_letters = []
self.failures = 0
self.game_mode = "0"
project.py - Game class
- quit: Method prints a message that it is quitting and quits the program
- is_game_finished: A helper method to determine if the game has ended (the user has won or lost)
- user_has_failed: A helper method to determine if the user has lost
- user_has_won: A helper method to determine if the user has won
- empty_space: A method used for CLI rendering to prevent repetition - DRY. Even if a lot more code could still be made less repetitive.
Final thoughts
Completing the course was a fun little project. I got to know Python basics enough to build on top of it. I can see why Python is so popular nowadays, but at the same time, I have some reservations towards it. I find its syntax a bit hard to read (significantly longer functions) and would want to see Python using more curly braces and less indentation. Python lacking type-safety is also something I cannot get used to.