Hangman
Guess letters before the wrong guesses run out. Tiny in scope, which makes it the best warm-up for modeling game state and separating rules from rendering.
Key Abstractions
The target. Knows its letters, its masked view, and which positions a letter lives in.
Owns state — remaining guesses, revealed positions, guessed-set, status
Strategy for picking the target — dictionary, fixed-for-tests, category-based
Value object returned per guess — hit, miss, duplicate, invalid
Per-player wins, losses, current streak, best streak
Plays a sequence of Games for one Player; updates PlayerStats on each terminal status
Class Diagram
The Key Insight
Hangman is tiny — which makes it the best way to show what separation-of-concerns looks like on an interview. The game is not a string, a counter, and a render function jammed into one class. It's three clean pieces: a Word that knows itself, a Game that owns guessed-letter state and the status transitions, and a WordProvider that's injected so tests are deterministic.
The masked view is the subtle bit. Storing the masked string mutably means every guess has to carefully rewrite it — and any mistake (wrong position, wrong case handling) is a permanent bug. Deriving the mask from Word + guessed set on demand is a few lines, always correct, and needs no invariant-maintaining code.
The per-guess result type matters for UX. A candidate who returns just "correct" or "incorrect" misses that "already guessed" and "not a letter" are real outcomes the UI must display differently. The GuessResult object captures all of them explicitly.
Requirements
Functional
- Start a game with a hidden word and max-wrong-guesses limit
- Guess a letter; get hit/miss/duplicate/invalid feedback
- Show the masked word after each guess
- Declare WON when all letters are guessed, LOST when wrong guesses run out
- Support case-insensitive guesses
Non-Functional
- O(1) per guess (set lookup)
- Deterministic in tests (fixed word provider)
- Word provider swappable — dictionary, category, difficulty-based
Design Decisions
Why Word as its own class?
A raw string works for this problem, but pulling it into a class makes the invariants explicit: "alphabetic, non-empty, stored lowercase." Any future feature (phrases with spaces, multi-word puzzles, accented letters) slots in with one class to update.
Why WordProvider as a Strategy?
Production picks from a dictionary. Tests must be deterministic ("pick 'algorithm'" → assertions on exact state). A mocked provider is a one-liner; mocking Random globally is not.
Why a GuessResult object?
Returning an enum is fine until the UI needs "masked state + guesses left + status" after each guess. A value object bundles them atomically, making the client less error-prone. Small type; big readability win.
Why case-insensitive?
Users type 'a' or 'A' interchangeably. Normalizing at the guess boundary (lowercase) means the rest of the code deals with one case. Storing the word lowercase prevents drift.
Why separate status from remaining count?
A candidate could infer "out of guesses" from remaining == 0, but that conflates state with state-derived signal. An explicit GameStatus keeps the state machine legible and makes it easy to add intermediate states (e.g., FORFEIT) later.
Interview Follow-ups
- "How would you support themed categories (animals, countries)?"
CategoryProviderwrapsDictionaryProviderbut filters by category tags. Game doesn't change. - "What about multi-word phrases?" Word class already decouples from "one contiguous string" — extend it to accept spaces as always-revealed. Tests cover "hello world" showing " " literally.
- "How do you support difficulty levels?"
DifficultyProviderthat picks shorter words for easy, rarer letters for hard, and adjusts max-wrong. Game constructor unchanged. - "How would a two-player version work?" One player picks the word (
FixedWordProviderfrom user input), the other guesses.Gametakes the provider; the UI layer wires it up. - "How would you add hint support?"
hint()method on Game reveals one still-unguessed letter, costs one remaining guess. Returns aHintResultvalue object similar toGuessResult.
Code Implementation
1 from __future__ import annotations
2 from abc import ABC, abstractmethod
3 from dataclasses import dataclass, field
4 from enum import Enum
5 import random
6
7
8 class Outcome(Enum):
9 HIT = "hit"
10 MISS = "miss"
11 DUPLICATE = "duplicate"
12 INVALID = "invalid"
13 GAME_OVER = "game_over"
14
15
16 class GameStatus(Enum):
17 IN_PROGRESS = "in_progress"
18 WON = "won"
19 LOST = "lost"
20
21
22 class Word:
23 """The hidden target. Case-insensitive; stored lowercase."""
24
25 def __init__(self, text: str):
26 if not text or not text.isalpha():
27 raise ValueError("word must be alphabetic and non-empty")
28 self._letters = text.lower()
29
30 def reveal(self, guessed: set[str]) -> str:
31 return " ".join(ch if ch in guessed else "_" for ch in self._letters)
32
33 def contains(self, letter: str) -> bool:
34 return letter.lower() in self._letters
35
36 def is_completely_guessed(self, guessed: set[str]) -> bool:
37 return all(ch in guessed for ch in self._letters)
38
39 @property
40 def length(self) -> int:
41 return len(self._letters)
42
43
44 class WordProvider(ABC):
45 @abstractmethod
46 def next(self) -> str: ...
47
48
49 class DictionaryProvider(WordProvider):
50 """Picks a word from a bundled dictionary."""
51
52 def __init__(self, words: list[str], rng: random.Random | None = None):
53 filtered = [w for w in words if w.isalpha() and len(w) >= 3]
54 if not filtered:
55 raise ValueError("no valid words in dictionary")
56 self._words = filtered
57 self._rng = rng or random.Random()
58
59 def next(self) -> str:
60 return self._rng.choice(self._words)
61
62
63 class FixedWordProvider(WordProvider):
64 """Deterministic provider for tests."""
65
66 def __init__(self, word: str):
67 self._word = word
68
69 def next(self) -> str:
70 return self._word
71
72
73 @dataclass
74 class GuessResult:
75 outcome: Outcome
76 masked: str
77 remaining_guesses: int
78 status: GameStatus
79
80
81 class Game:
82 DEFAULT_MAX_WRONG = 6
83
84 def __init__(self, provider: WordProvider, max_wrong: int = DEFAULT_MAX_WRONG):
85 if max_wrong < 1:
86 raise ValueError("max_wrong must be >= 1")
87 self._word = Word(provider.next())
88 self._guessed: set[str] = set()
89 self._remaining = max_wrong
90 self._status = GameStatus.IN_PROGRESS
91
92 @property
93 def status(self) -> GameStatus:
94 return self._status
95
96 @property
97 def remaining(self) -> int:
98 return self._remaining
99
100 def masked(self) -> str:
101 return self._word.reveal(self._guessed)
102
103 def guess(self, letter: str) -> GuessResult:
104 if self._status != GameStatus.IN_PROGRESS:
105 return GuessResult(Outcome.GAME_OVER, self.masked(), self._remaining, self._status)
106
107 if len(letter) != 1 or not letter.isalpha():
108 return GuessResult(Outcome.INVALID, self.masked(), self._remaining, self._status)
109
110 letter = letter.lower()
111 if letter in self._guessed:
112 return GuessResult(Outcome.DUPLICATE, self.masked(), self._remaining, self._status)
113
114 self._guessed.add(letter)
115 if self._word.contains(letter):
116 if self._word.is_completely_guessed(self._guessed):
117 self._status = GameStatus.WON
118 return GuessResult(Outcome.HIT, self.masked(), self._remaining, self._status)
119
120 self._remaining -= 1
121 if self._remaining == 0:
122 self._status = GameStatus.LOST
123 return GuessResult(Outcome.MISS, self.masked(), self._remaining, self._status)
124
125 def reveal_answer(self) -> str:
126 if self._status == GameStatus.IN_PROGRESS:
127 raise RuntimeError("cannot reveal while game is in progress")
128 return self._word._letters
129
130
131 @dataclass
132 class PlayerStats:
133 wins: int = 0
134 losses: int = 0
135 current_streak: int = 0
136 best_streak: int = 0
137
138 def record(self, status: GameStatus) -> None:
139 if status == GameStatus.WON:
140 self.wins += 1
141 self.current_streak += 1
142 self.best_streak = max(self.best_streak, self.current_streak)
143 elif status == GameStatus.LOST:
144 self.losses += 1
145 self.current_streak = 0
146
147
148 @dataclass
149 class Player:
150 name: str
151 stats: PlayerStats = field(default_factory=PlayerStats)
152
153
154 class HangmanSession:
155 """Plays a sequence of games for one player, updating stats on each terminal status."""
156
157 def __init__(self, player: Player, provider: WordProvider,
158 max_wrong: int = Game.DEFAULT_MAX_WRONG):
159 self._player = player
160 self._provider = provider
161 self._max_wrong = max_wrong
162 self._current: Game | None = None
163
164 @property
165 def player(self) -> Player:
166 return self._player
167
168 def start(self) -> Game:
169 self._current = Game(self._provider, max_wrong=self._max_wrong)
170 return self._current
171
172 def guess(self, letter: str) -> GuessResult:
173 if self._current is None:
174 raise RuntimeError("start() a game first")
175 result = self._current.guess(letter)
176 if result.status in (GameStatus.WON, GameStatus.LOST):
177 self._player.stats.record(result.status)
178 return result
179
180
181 if __name__ == "__main__":
182 game = Game(FixedWordProvider("algorithm"))
183 print(f"Masked: {game.masked()} remaining: {game.remaining}")
184
185 for letter in "aeiounrx":
186 r = game.guess(letter)
187 tag = r.outcome.value.upper()
188 print(f" guess {letter!r} -> {tag} masked: {r.masked} rem: {r.remaining_guesses}")
189
190 # Duplicate, invalid, and game-continuing tests.
191 dup = game.guess("a")
192 assert dup.outcome == Outcome.DUPLICATE
193 invalid = game.guess("1")
194 assert invalid.outcome == Outcome.INVALID
195 print(f"Duplicate detected: {dup.outcome.value}, invalid detected: {invalid.outcome.value}")
196
197 # Finish the game.
198 for letter in "lgthm":
199 game.guess(letter)
200 print(f"\nFinal status: {game.status.value}, word was: {game.reveal_answer()!r}")
201
202 # Short-word losing scenario.
203 loser = Game(FixedWordProvider("cat"), max_wrong=3)
204 for letter in "xyzq":
205 result = loser.guess(letter)
206 print(f"\nLoser status: {loser.status.value}, word was: {loser.reveal_answer()!r}")
207
208 # Session: two wins then a loss — streak should reset, best_streak = 2.
209 alice = Player("Alice")
210 words = ["cat", "dog", "zebra"]
211 idx = {"i": 0}
212 class _SeqProvider(WordProvider):
213 def next(self) -> str:
214 w = words[idx["i"]]
215 idx["i"] += 1
216 return w
217 session = HangmanSession(alice, _SeqProvider(), max_wrong=6)
218
219 # Win 'cat' — guess c, a, t.
220 session.start()
221 for ch in "cat":
222 session.guess(ch)
223
224 # Win 'dog' — guess d, o, g.
225 session.start()
226 for ch in "dog":
227 session.guess(ch)
228
229 # Lose 'zebra' — 6 wrong guesses from letters not in "zebra".
230 session.start()
231 for ch in "fhijkl":
232 session.guess(ch)
233
234 print(f"\nAlice stats: wins={alice.stats.wins}, losses={alice.stats.losses}, "
235 f"streak={alice.stats.current_streak}, best={alice.stats.best_streak}")
236 assert alice.stats.wins == 2 and alice.stats.losses == 1
237 assert alice.stats.current_streak == 0 and alice.stats.best_streak == 2Common Mistakes
- ✗Storing the masked word as mutable state. Drift between target and masked is how bugs creep in.
- ✗Treating the guess as 'correct or incorrect' — there's also 'already guessed' and 'invalid character' for UX.
- ✗Case-insensitive matching but case-sensitive storage. Normalize at the boundary.
- ✗Running out of guesses silently. Signal it through status + event, not just a remaining counter.
Key Points
- ✓Track guessed letters in a set, not a list. Duplicate guesses are identifiable in O(1).
- ✓Masked view is derived from Word + guessed set. Don't store the masked string — recompute.
- ✓Game status is a state machine: IN_PROGRESS → WON (all letters guessed) → LOST (out of guesses).
- ✓WordProvider as Strategy keeps the game deterministic in tests (FixedWordProvider) while production uses dictionary.
- ✓Session wraps Game so stats tracking is outside the single-game core — same Game class, optional stats.