I still see Hangman as one of the best first games for learning real programming habits, not just syntax. You get loops, data structures, input validation, and state management in one tight package. The hidden win is that a tiny game forces you to think about user experience and correctness in the same breath. If you skip that step early, your later projects feel brittle.
I am going to show you how I build a Hangman game today: starting from a minimal, playable version, then shaping it into something you can test, extend, and ship. You will learn how to represent game state cleanly, how to validate input without annoying the player, and how to structure code so the next feature is easy rather than painful. I will also point out common mistakes, edge cases, and a few 2026‑ready workflow tricks that can save you hours.
Why Hangman is a great programming kata
When I am mentoring newer developers, I pick Hangman because it rewards correctness and clarity. The rules are small, but you still need to manage a handful of moving parts: the secret word, the set of guessed letters, how many tries are left, and when the game ends. The game forces you to model state rather than just print text.
You also get immediate feedback while you code. Each guess is a small test case, and you can feel when the logic drifts. If you build the game with readable state transitions, you set yourself up for larger systems later. I have seen people jump from a clean Hangman implementation to building bots, CLIs, and microservices without fear.
I recommend treating Hangman as a stepping stone into real-world practices: pure functions where possible, small modules, and a separate layer for input/output. Even if the game is tiny, that structure pays off the moment you add difficulty levels or a hint system.
The game model: state, transitions, and victory
At its core, Hangman is a state machine. The state is the secret word, a collection of guessed letters, and the remaining attempts. Each guess creates a transition: either you reveal letters or you lose a try. You win when all letters are revealed; you lose when tries hit zero.
I like to treat the secret word as a list of characters or a string. For guessed letters, a set works best because membership checks are fast and duplicates are automatically ignored. The display word is derived from those two pieces, so you never store it directly. That avoids bugs where the display gets out of sync with the truth.
In terms of complexity, each guess should be O(n) at worst, where n is the word length. For words under 20 letters, that is usually under 0.1–0.5ms on a modern laptop. That is far beyond fast enough for a terminal game.
Here is the mental model I use:
- input: a single letter guess
- validate: alphabetic, single char, not already guessed
- update: add to guessed set, adjust tries only if wrong
- render: derive the masked word and show remaining tries
- check: win or lose conditions
Once you have this model clear, code becomes straightforward.
A clean, runnable baseline implementation
Below is a complete, runnable Hangman game. I focus on clarity and correctness, not tricks. It keeps I/O in one place and uses small helper functions for testability.
import random
from typing import List, Set
WORDS = [
‘apple‘, ‘banana‘, ‘mango‘, ‘strawberry‘, ‘orange‘, ‘grape‘,
‘pineapple‘, ‘apricot‘, ‘lemon‘, ‘coconut‘, ‘watermelon‘,
‘cherry‘, ‘papaya‘, ‘berry‘, ‘peach‘, ‘lychee‘, ‘muskmelon‘
]
def pick_word(words: List[str]) -> str:
return random.choice(words)
def mask_word(word: str, guessed: Set[str]) -> str:
# Reveal letters that have been guessed, mask the rest.
return ‘ ‘.join([ch if ch in guessed else ‘_‘ for ch in word])
def iswordsolved(word: str, guessed: Set[str]) -> bool:
return all(ch in guessed for ch in word)
def read_guess(guessed: Set[str]) -> str:
while True:
guess = input(‘Enter a letter to guess: ‘).strip().lower()
if len(guess) != 1:
print(‘Please enter a single letter.‘)
continue
if not guess.isalpha():
print(‘Please enter a letter from a to z.‘)
continue
if guess in guessed:
print(‘You already guessed that letter.‘)
continue
return guess
def play_game() -> None:
word = pick_word(WORDS)
guessed: Set[str] = set()
remaining = len(word) + 2
print(‘Guess the word! Hint: it is a fruit name‘)
print(mask_word(word, guessed))
while remaining > 0:
guess = read_guess(guessed)
guessed.add(guess)
if guess not in word:
remaining -= 1
print(f‘Nope. Remaining tries: {remaining}‘)
else:
print(‘Nice!‘)
current = mask_word(word, guessed)
print(current)
if iswordsolved(word, guessed):
print(f‘You won! The word is {word}.‘)
return
print(f‘You lost! The word was {word}.‘)
if name == ‘main‘:
play_game()
Why I like this baseline:
- It is fully playable with clear feedback.
- It separates core logic from input validation.
- It is short enough to reason about while still showing real structure.
If you are teaching or learning, start here and grow outward.
Modernizing input handling and user experience
The baseline game is solid, but players feel the friction if input handling is harsh. I aim for a gentle loop that teaches the player what is expected without spamming them. In modern practice, I also add small quality-of-life features: a visible alphabet of remaining letters and a quick exit command.
Here is an updated version that adds those features without bloating the code. I also expose a small config so you can vary difficulty.
import random
from dataclasses import dataclass
from typing import Set, List
WORDS = [
‘apple‘, ‘banana‘, ‘mango‘, ‘strawberry‘, ‘orange‘, ‘grape‘,
‘pineapple‘, ‘apricot‘, ‘lemon‘, ‘coconut‘, ‘watermelon‘,
‘cherry‘, ‘papaya‘, ‘berry‘, ‘peach‘, ‘lychee‘, ‘muskmelon‘
]
ALPHABET = ‘abcdefghijklmnopqrstuvwxyz‘
@dataclass(frozen=True)
class GameConfig:
extra_attempts: int = 2
allow_exit: bool = True
def pick_word(words: List[str]) -> str:
return random.choice(words)
def mask_word(word: str, guessed: Set[str]) -> str:
return ‘ ‘.join([ch if ch in guessed else ‘_‘ for ch in word])
def remaining_letters(guessed: Set[str]) -> str:
return ‘‘.join([ch for ch in ALPHABET if ch not in guessed])
def read_guess(guessed: Set[str], config: GameConfig) -> str:
while True:
raw = input(‘Enter a letter (or ! to quit): ‘).strip().lower()
if config.allow_exit and raw == ‘!‘:
return ‘!‘
if len(raw) != 1:
print(‘Please enter a single letter.‘)
continue
if not raw.isalpha():
print(‘Please enter a letter from a to z.‘)
continue
if raw in guessed:
print(‘You already guessed that letter.‘)
continue
return raw
def play_game(config: GameConfig) -> None:
word = pick_word(WORDS)
guessed: Set[str] = set()
remaining = len(word) + config.extra_attempts
print(‘Guess the word! Hint: it is a fruit name‘)
while remaining > 0:
print(f‘Word: {mask_word(word, guessed)}‘)
print(f‘Available letters: {remaining_letters(guessed)}‘)
print(f‘Remaining tries: {remaining}‘)
guess = read_guess(guessed, config)
if guess == ‘!‘:
print(‘Goodbye!‘)
return
guessed.add(guess)
if guess not in word:
remaining -= 1
print(‘Nope.‘)
else:
print(‘Nice.‘)
if all(ch in guessed for ch in word):
print(f‘You won! The word is {word}.‘)
return
print(f‘You lost! The word was {word}.‘)
if name == ‘main‘:
play_game(GameConfig())
From a 2026 perspective, I also like to add a small sound or emoji layer when running in a richer terminal, but I keep that optional. The code above is still simple and remains testable.
Designing for testability and reuse
In real projects, you want your game logic to be testable without a terminal. The fastest way to reach that goal is to separate state transitions from I/O. I keep a small state object and make a pure function that applies a guess.
This pattern scales smoothly: you can plug it into a CLI, a web UI, or even a chat bot without rewriting the rules. Here is a focused approach.
from dataclasses import dataclass
from typing import Set, Tuple
@dataclass
class GameState:
word: str
guessed: Set[str]
remaining: int
def apply_guess(state: GameState, guess: str) -> Tuple[GameState, str]:
# Returns new state and a message.
if guess in state.guessed:
return state, ‘Already guessed.‘
next_guessed = set(state.guessed)
next_guessed.add(guess)
if guess in state.word:
return GameState(state.word, next_guessed, state.remaining), ‘Nice.‘
return GameState(state.word, next_guessed, state.remaining - 1), ‘Nope.‘
def is_solved(state: GameState) -> bool:
return all(ch in state.guessed for ch in state.word)
Now you can unit test with almost no friction. For example, I typically test that incorrect guesses reduce remaining tries, correct guesses keep them intact, and repeated guesses do not change state. That tiny effort catches most bugs before you ever play the game.
When you write in this style, you also make refactors safe. You can change the CLI prompt or add a GUI without touching the core rules. That separation is a real professional habit.
Mistakes, edge cases, and fairness
I see the same mistakes repeat when people build Hangman for the first time. Avoiding them will save you time and make your game feel fair.
Common mistakes and fixes:
- Counting repeated letters incorrectly. If the word is ‘apple‘ and you guess ‘p‘, you should reveal both p’s. Using a set for guessed letters and masking by membership handles this cleanly.
- Failing to normalize case. I always lower-case both the word and guesses. If you ever mix cases, you will get ghost bugs that appear random.
- Penalizing repeated guesses. Most players do not expect to lose a try for an accidental repeat. I do not deduct tries for repeated guesses.
- Allowing multi-character guesses. If you want to support guessing the entire word, that should be a separate command, not a side effect of free-form input.
- Using short word lists. A tiny list makes the game trivial after a few rounds. You can fix this by loading words from a file and filtering by length.
Fairness also matters. If the word has rare letters, you should consider giving slightly more tries, or show a category hint. I tend to set tries to len(word) + 2 for beginner-friendly play. For more challenging games, I use len(word) + 1 or a flat 6–8 tries. Choose the rule and tell the player clearly.
When not to use this pattern: if the game is part of a competitive or educational setting where you need strict rules or accessibility. In those cases, you should design with consistent turn timing, screen reader support, and stricter input handling. For a friendly terminal game, the relaxed rules are fine.
Traditional vs modern workflow
I still respect the traditional way: build a single file, test manually, and iterate. It teaches fundamental control flow. But in 2026, I reach for a lightweight workflow that adds confidence without overhead.
Traditional approach
—
Inline list in code
words.txt, filter by length Inline checks in loop
read_guess or parsing function Mutable globals
GameState data class + pure functions Play manually
apply_guess Basic editor
I recommend the modern column for anyone who plans to extend the game. I use AI pair tools to propose tests, but I still review each test manually. You want help, not blind trust. A two-minute review is worth it for a reliable codebase.
A small note on performance: even with a list of 10,000 words loaded into memory, the game remains fast and responsive. The real delay is human input, not code. If you notice any lag, it is almost always due to heavy logging or a slow terminal, not the game loop.
Word lists and data hygiene: how I choose words
A Hangman game lives or dies by its word list. If your list is too small, the player will memorize it. If it is too weird, the game becomes a vocabulary exam instead of a logic puzzle. I use three simple rules when assembling words:
1) Keep words lowercase and alphabetic only. That avoids surprises with punctuation, hyphens, or accents that a basic terminal game may not handle well.
2) Keep word lengths in a reasonable range for the difficulty you want. For beginners, 4–8 is friendly. For advanced play, 7–12 is a good target.
3) Keep categories consistent so your hints are honest. If you say “animals,” your list should be animals, not a grab bag.
If you want to level up, load a word list from a file and filter by length or category at runtime. Here is a small, practical approach that avoids loading junk into memory and still stays readable:
from pathlib import Path
from typing import List
def loadwords(path: str, minlen: int, max_len: int) -> List[str]:
words = []
for line in Path(path).read_text().splitlines():
w = line.strip().lower()
if not w.isalpha():
continue
if minlen <= len(w) <= maxlen:
words.append(w)
if not words:
raise ValueError(‘No valid words loaded. Check your file or filters.‘)
return words
This function gives you a clean list or a clear error. It also solves a common bug where the game starts with an empty list and crashes later in a confusing way.
A stronger CLI architecture: parse, update, render
When a CLI grows even a little, I prefer the parse-update-render loop (sometimes called the game loop pattern). It keeps responsibilities clean:
- parse: turn raw input into a structured command
- update: apply the command to state
- render: print a new view of the state
This is the exact same structure used by larger interactive apps, just simplified. Here is a focused CLI skeleton that uses the pure GameState approach but feels more like a real application:
from dataclasses import dataclass
from typing import Set, Optional
@dataclass
class GameState:
word: str
guessed: Set[str]
remaining: int
@dataclass
class Command:
kind: str
value: Optional[str] = None
def parse_command(raw: str) -> Command:
text = raw.strip().lower()
if text in (‘!‘,‘quit‘,‘exit‘):
return Command(‘quit‘)
if len(text) == 1 and text.isalpha():
return Command(‘guess‘, text)
if text.startswith(‘word ‘) and text[5:].isalpha():
return Command(‘word‘, text[5:])
return Command(‘invalid‘)
def apply_command(state: GameState, cmd: Command) -> tuple[GameState, str, bool]:
if cmd.kind == ‘quit‘:
return state, ‘Goodbye!‘, True
if cmd.kind == ‘invalid‘:
return state, ‘Enter a single letter, or type quit.‘, False
if cmd.kind == ‘guess‘:
if cmd.value in state.guessed:
return state, ‘Already guessed.‘, False
guessed = set(state.guessed)
guessed.add(cmd.value)
if cmd.value in state.word:
return GameState(state.word, guessed, state.remaining), ‘Nice.‘, False
return GameState(state.word, guessed, state.remaining - 1), ‘Nope.‘, False
if cmd.kind == ‘word‘:
# Optional full-word guess, costs one attempt if wrong.
if cmd.value == state.word:
guessed = set(state.word)
return GameState(state.word, guessed, state.remaining), ‘Correct!‘, False
return GameState(state.word, state.guessed, state.remaining - 1), ‘Wrong word.‘, False
return state, ‘Unhandled command.‘, False
This gives you three clear seams where you can add features later. For example, you can log every command in apply_command, add an “undo” feature, or attach an analytics counter without touching the input parser.
A complete, testable project layout
If you are serious about learning, move beyond a single file and try a small project layout. This looks like overkill, but it keeps your logic clean and teaches real-world structure:
hangman/
hangman/
init.py
model.py
io.py
words.py
tests/
test_model.py
main.py
The roles are simple:
model.pyholdsGameState,apply_guess, and win/loss checks.io.pyhandles input/output prompts and rendering.words.pyloads and filters word lists.main.pywires everything together.
This layout makes it easy to test the model without a terminal and to swap out io.py later for a web interface.
Testing strategy that actually pays off
If you only write one set of tests, target your state transitions. That is where bugs hide. I keep the tests tiny and precise. The goal is confidence, not coverage bragging rights.
Here is a minimal test file that catches the most common issues:
from hangman.model import GameState, applyguess, issolved
def testwrongguessreducesremaining():
state = GameState(‘apple‘, set(), 5)
nextstate, msg = applyguess(state, ‘z‘)
assert next_state.remaining == 4
assert msg == ‘Nope.‘
def testcorrectguesskeepsremaining():
state = GameState(‘apple‘, set(), 5)
nextstate, msg = applyguess(state, ‘p‘)
assert next_state.remaining == 5
assert ‘p‘ in next_state.guessed
assert msg == ‘Nice.‘
def testrepeatedguessnopenalty():
state = GameState(‘apple‘, {‘a‘}, 5)
nextstate, msg = applyguess(state, ‘a‘)
assert next_state.remaining == 5
assert msg == ‘Already guessed.‘
def testissolved_true():
state = GameState(‘hi‘, {‘h‘,‘i‘}, 3)
assert is_solved(state) is True
These four tests catch the biggest behavioral regressions. I often add one more that checks repeated letters in the word are revealed properly by the mask logic.
Robust input validation without annoying the player
Input validation is a user experience feature, not just a correctness rule. The trap is scolding the player in every loop. I prefer a gentle message plus a short example of valid input.
A helpful pattern is to keep a single “last error” message and only print it if the user repeats a mistake. That reduces spam. Another pattern is to highlight the expected input in the prompt itself so you rarely need to print errors.
Here is a small improvement that keeps the prompt informative:
prompt = ‘Guess a letter (a-z), or type quit: ‘
raw = input(prompt).strip().lower()
When you do have to reject input, keep the message short and actionable. “Enter one letter from a to z” is better than “Invalid input.”
Alternative approaches: object-oriented vs functional
Some developers prefer an object-oriented version. That can be nice when you want methods like state.guess(‘a‘) and state.render(). Functional style is often easier to test and reason about, but both can be clean.
Here is a compact OOP approach that still behaves predictably:
from dataclasses import dataclass, field
from typing import Set
@dataclass
class Hangman:
word: str
remaining: int
guessed: Set[str] = field(default_factory=set)
def guess(self, ch: str) -> str:
if ch in self.guessed:
return ‘Already guessed.‘
self.guessed.add(ch)
if ch in self.word:
return ‘Nice.‘
self.remaining -= 1
return ‘Nope.‘
def solved(self) -> bool:
return all(ch in self.guessed for ch in self.word)
def mask(self) -> str:
return ‘ ‘.join(ch if ch in self.guessed else ‘_‘ for ch in self.word)
I reach for this style when I want the state and behavior to live together or if I plan to subclass for variants (like themed categories or timed rounds). For small games, both styles are fine; consistency matters more than the paradigm.
Handling full-word guesses and hint systems
Adding full-word guesses is a common request. The risk is breaking the input validation and game pacing. I treat it as an explicit command so the player can’t accidentally guess the entire word.
A simple rule I use: a full-word guess costs one attempt if wrong and ends the game if right. This keeps it fair and discourages random spamming of words.
A hint system is also popular, but it can easily undermine the game. My approach is to trade hints for attempts. For example: “Reveal one random unguessed letter at the cost of 2 attempts.” That keeps the game balanced.
Pseudo-logic for hints:
- pick a letter from
wordthat is not inguessed - add it to
guessed - reduce
remainingby hint cost - return a message: “Hint used: revealed ‘x’”
This is easy to add if you already keep state transitions in a pure function.
Scoring and replay loops without clutter
If you want scoring, keep it simple. A classic rule is: score = remaining attempts at win, zero on loss. If you want a better curve, use a small bonus for longer words.
A replay loop is also easy to add without complicating the core loop. My rule is: keep the play_game function focused on a single game, then wrap it with a main() function that asks if the player wants to play again.
Example pattern:
def main():
while True:
play_game(GameConfig())
again = input(‘Play again? (y/n): ‘).strip().lower()
if again != ‘y‘:
break
This isolates the replay feature so you can remove it later for testing or integration into another interface.
Performance considerations: what matters and what does not
For a terminal hangman game, the bottleneck is almost always the player, not the CPU. But performance still matters if you scale the word list or add advanced features.
- Masking with a list comprehension is fast enough for typical word lengths.
- Checking membership in a set is O(1) average and extremely fast.
- Loading a large word list from disk can be slow, so load it once at startup and reuse it.
If you want to measure performance, use ranges instead of pretending to be exact. For example:
- Masking a 10–12 letter word is often under 0.1ms.
- Loading a 50k word list can take 20–120ms depending on disk speed.
Those numbers aren’t precise, but the point is that you are unlikely to see a performance issue unless you add heavy I/O or expensive rendering.
Security and input safety even in small games
Even a tiny CLI can teach you good safety habits. If you load a word list from a path supplied by the user, validate it and handle errors gracefully. If you accept commands, keep parsing strict so you do not accidentally treat arbitrary input as a filename or a command to execute.
I also prefer to avoid eval entirely. There is no reason for it in a Hangman game, and modeling safer alternatives early is good practice.
Accessibility and inclusive design choices
If you plan to share your game, consider accessibility early. A few simple changes make it friendlier:
- Provide category hints so players are not guessing in the dark.
- Avoid relying purely on color to show success/failure.
- Keep prompts short and consistent so screen readers are less noisy.
- Allow quitting at any time without penalty.
You do not need to build a full accessibility stack for a CLI, but small choices add a lot of polish and show professional care.
Building a web or GUI version without rewriting the logic
Once you have a clean GameState and apply_guess, you can build a web version with minimal changes. The trick is to avoid mixing logic and UI. Your rules should be UI-agnostic.
For a minimal web version, a simple server can expose two endpoints:
/startto begin a new game and return a masked word/guessto apply a guess and return the updated state
The front end can be a single HTML page with a small fetch loop. Because the logic is already clean, the “web version” becomes an adapter rather than a rewrite.
If you are practicing full-stack skills, this is a great exercise. Keep it small, and focus on correctness and state management before you add visual flourishes.
Production considerations: logging and monitoring (yes, even here)
It sounds silly to talk about monitoring for a game, but the habit matters. If you build a web version, add basic logging for state changes and errors. It will save you time if you deploy the game for a class or a demo.
A minimal approach is:
- log when a new game starts
- log invalid guesses (for debugging)
- log win/loss outcomes
If you ever find yourself investigating a bug, those logs are priceless.
Common pitfalls in extended versions
Once you extend the game, new pitfalls appear. I see these a lot:
- Storing the masked word instead of deriving it. This leads to state desync bugs.
- Updating guessed letters in two places (for example, both in the model and in the UI layer). This creates inconsistencies.
- Mixing parsing rules with game rules. Parsing should be strict and independent.
- Forgetting to reset state when replaying. The game can start with stale guesses or remaining tries.
The fix is always the same: keep a single source of truth for state and derive everything else.
Alternative mechanics and variants worth trying
If you want to go beyond the classic rules, here are some variants that are still easy to implement:
- Timed Hangman: add a time limit per guess and end the game if time runs out.
- Progressive hints: reveal a letter automatically every N wrong guesses.
- “No vowels” mode: remove vowels from the word list and see how it plays.
- Competitive mode: players take turns guessing; each wrong guess costs their team.
Each variant teaches a different skill: timers, turn management, or rule configuration. Keep your core logic clean and these become simple toggles.
Practical scenarios: when to use vs when NOT to use Hangman
Use Hangman when you want to teach or practice:
- state management and transitions
- input validation and user feedback
- separation of concerns and basic testing
Avoid Hangman when you need to teach:
- graphics-heavy programming
- asynchronous event-driven logic
- complex data persistence
It is a great starter kata, but it does have limits. The trick is to extract the right lessons and then graduate to the next project.
A deeper, real-world example: full loop with parsing and tests
Below is a more complete example that ties together parsing, state updates, and rendering. It is still compact, but it feels more like a real application and is easy to test.
from dataclasses import dataclass
from typing import Set, Optional
import random
WORDS = [‘python‘, ‘terminal‘, ‘function‘, ‘variable‘, ‘object‘]
ALPHABET = ‘abcdefghijklmnopqrstuvwxyz‘
@dataclass
class GameState:
word: str
guessed: Set[str]
remaining: int
def pick_word() -> str:
return random.choice(WORDS)
def mask_word(word: str, guessed: Set[str]) -> str:
return ‘ ‘.join(ch if ch in guessed else ‘_‘ for ch in word)
def is_solved(state: GameState) -> bool:
return all(ch in state.guessed for ch in state.word)
def remaining_letters(guessed: Set[str]) -> str:
return ‘‘.join(ch for ch in ALPHABET if ch not in guessed)
def parse_guess(raw: str) -> Optional[str]:
text = raw.strip().lower()
if text in (‘quit‘,‘exit‘,‘!‘):
return ‘!‘
if len(text) == 1 and text.isalpha():
return text
return None
def apply_guess(state: GameState, guess: str) -> tuple[GameState, str]:
if guess in state.guessed:
return state, ‘Already guessed.‘
guessed = set(state.guessed)
guessed.add(guess)
if guess in state.word:
return GameState(state.word, guessed, state.remaining), ‘Nice.‘
return GameState(state.word, guessed, state.remaining - 1), ‘Nope.‘
def play() -> None:
state = GameState(word=pick_word(), guessed=set(), remaining=8)
print(‘Hangman: guess the programming word‘)
while state.remaining > 0:
print(f‘Word: {mask_word(state.word, state.guessed)}‘)
print(f‘Available: {remaining_letters(state.guessed)}‘)
print(f‘Remaining tries: {state.remaining}‘)
raw = input(‘Guess (or quit): ‘)
guess = parse_guess(raw)
if guess is None:
print(‘Enter a single letter or type quit.‘)
continue
if guess == ‘!‘:
print(‘Goodbye!‘)
return
state, msg = apply_guess(state, guess)
print(msg)
if is_solved(state):
print(f‘You won! The word is {state.word}.‘)
return
print(f‘You lost! The word was {state.word}.‘)
if name == ‘main‘:
play()
This version balances clarity and structure. It is also easy to test because the parsing, state transitions, and rendering are distinct.
Common debugging workflow (what I actually do)
When a Hangman game misbehaves, I debug in three steps:
1) Reproduce with the smallest word and guess set I can.
2) Print the state before and after applying a guess.
3) Check the invariants: remaining attempts never increase, guessed letters never shrink, and the word never changes.
If I still cannot see the bug, I write a quick test for the failing scenario. That test then becomes a permanent guard. It sounds slow, but it saves time the next time you refactor.
Integrating AI-assisted tools in 2026 (without losing control)
I use AI-assisted tools as a second set of eyes, not as an autopilot. They are great for generating a first draft of tests or for refactoring a function into a more idiomatic version. But I still verify every change.
My workflow is:
- Write the core logic myself.
- Ask for test suggestions.
- Review tests for correctness and edge coverage.
- Run tests and fix any surprises.
This keeps me in control while still benefiting from speed. Think of AI as a fast collaborator, not a decision-maker.
When to stop adding features
It is easy to keep adding features and lose the original learning goal. I stop when:
- The rules are stable and clean.
- I can add one new feature without touching the core model.
- Tests give me confidence to refactor.
At that point, I move on to a new project or a new interface for the same project. Hangman is a training ground, not a permanent home.
Where to go next
At this point, you have a Hangman game that is readable, testable, and friendly to the player. The best next step is to add one new feature at a time while keeping the rules stable. I usually start with a replay loop and a difficulty selector, then add scoring and a small leaderboard. If you feel ambitious, add categories and load words from a JSON file or a small SQLite database.
I also like to build a tiny web version after the CLI is stable. A single-page app or a minimal service lets you reuse the core logic and practice different delivery layers. When you do that, keep your GameState and apply_guess in a shared module. That keeps your rules consistent across interfaces.
If you are using this as a learning exercise, I recommend writing three or four unit tests for the core logic. That habit is more important than the game itself. You will feel the payoff the first time you refactor without fear.
Finally, treat the game as your playground for thoughtful code. Every improvement you make here—clear state, input validation, clean separation—will show up in bigger systems later. That is why I still build Hangman when I want to sharpen my fundamentals, and why I encourage you to do the same.


