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
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
import random
class Outcome(Enum):
HIT = "hit"
MISS = "miss"
DUPLICATE = "duplicate"
INVALID = "invalid"
GAME_OVER = "game_over"
class GameStatus(Enum):
IN_PROGRESS = "in_progress"
WON = "won"
LOST = "lost"
class Word:
"""The hidden target. Case-insensitive; stored lowercase."""
def __init__(self, text: str):
if not text or not text.isalpha():
raise ValueError("word must be alphabetic and non-empty")
self._letters = text.lower()
def reveal(self, guessed: set[str]) -> str:
return " ".join(ch if ch in guessed else "_" for ch in self._letters)
def contains(self, letter: str) -> bool:
return letter.lower() in self._letters
def is_completely_guessed(self, guessed: set[str]) -> bool:
return all(ch in guessed for ch in self._letters)
@property
def length(self) -> int:
return len(self._letters)
class WordProvider(ABC):
@abstractmethod
def next(self) -> str: ...
class DictionaryProvider(WordProvider):
"""Picks a word from a bundled dictionary."""
def __init__(self, words: list[str], rng: random.Random | None = None):
filtered = [w for w in words if w.isalpha() and len(w) >= 3]
if not filtered:
raise ValueError("no valid words in dictionary")
self._words = filtered
self._rng = rng or random.Random()
def next(self) -> str:
return self._rng.choice(self._words)
class FixedWordProvider(WordProvider):
"""Deterministic provider for tests."""
def __init__(self, word: str):
self._word = word
def next(self) -> str:
return self._word
@dataclass
class GuessResult:
outcome: Outcome
masked: str
remaining_guesses: int
status: GameStatus
class Game:
DEFAULT_MAX_WRONG = 6
def __init__(self, provider: WordProvider, max_wrong: int = DEFAULT_MAX_WRONG):
if max_wrong < 1:
raise ValueError("max_wrong must be >= 1")
self._word = Word(provider.next())
self._guessed: set[str] = set()
self._remaining = max_wrong
self._status = GameStatus.IN_PROGRESS
@property
def status(self) -> GameStatus:
return self._status
@property
def remaining(self) -> int:
return self._remaining
def masked(self) -> str:
return self._word.reveal(self._guessed)
def guess(self, letter: str) -> GuessResult:
if self._status != GameStatus.IN_PROGRESS:
return GuessResult(Outcome.GAME_OVER, self.masked(), self._remaining, self._status)
if len(letter) != 1 or not letter.isalpha():
return GuessResult(Outcome.INVALID, self.masked(), self._remaining, self._status)
letter = letter.lower()
if letter in self._guessed:
return GuessResult(Outcome.DUPLICATE, self.masked(), self._remaining, self._status)
self._guessed.add(letter)
if self._word.contains(letter):
if self._word.is_completely_guessed(self._guessed):
self._status = GameStatus.WON
return GuessResult(Outcome.HIT, self.masked(), self._remaining, self._status)
self._remaining -= 1
if self._remaining == 0:
self._status = GameStatus.LOST
return GuessResult(Outcome.MISS, self.masked(), self._remaining, self._status)
def reveal_answer(self) -> str:
if self._status == GameStatus.IN_PROGRESS:
raise RuntimeError("cannot reveal while game is in progress")
return self._word._letters
@dataclass
class PlayerStats:
wins: int = 0
losses: int = 0
current_streak: int = 0
best_streak: int = 0
def record(self, status: GameStatus) -> None:
if status == GameStatus.WON:
self.wins += 1
self.current_streak += 1
self.best_streak = max(self.best_streak, self.current_streak)
elif status == GameStatus.LOST:
self.losses += 1
self.current_streak = 0
@dataclass
class Player:
name: str
stats: PlayerStats = field(default_factory=PlayerStats)
class HangmanSession:
"""Plays a sequence of games for one player, updating stats on each terminal status."""
def __init__(self, player: Player, provider: WordProvider,
max_wrong: int = Game.DEFAULT_MAX_WRONG):
self._player = player
self._provider = provider
self._max_wrong = max_wrong
self._current: Game | None = None
@property
def player(self) -> Player:
return self._player
def start(self) -> Game:
self._current = Game(self._provider, max_wrong=self._max_wrong)
return self._current
def guess(self, letter: str) -> GuessResult:
if self._current is None:
raise RuntimeError("start() a game first")
result = self._current.guess(letter)
if result.status in (GameStatus.WON, GameStatus.LOST):
self._player.stats.record(result.status)
return result
if __name__ == "__main__":
game = Game(FixedWordProvider("algorithm"))
print(f"Masked: {game.masked()} remaining: {game.remaining}")
for letter in "aeiounrx":
r = game.guess(letter)
tag = r.outcome.value.upper()
print(f" guess {letter!r} -> {tag} masked: {r.masked} rem: {r.remaining_guesses}")
# Duplicate, invalid, and game-continuing tests.
dup = game.guess("a")
assert dup.outcome == Outcome.DUPLICATE
invalid = game.guess("1")
assert invalid.outcome == Outcome.INVALID
print(f"Duplicate detected: {dup.outcome.value}, invalid detected: {invalid.outcome.value}")
# Finish the game.
for letter in "lgthm":
game.guess(letter)
print(f"\nFinal status: {game.status.value}, word was: {game.reveal_answer()!r}")
# Short-word losing scenario.
loser = Game(FixedWordProvider("cat"), max_wrong=3)
for letter in "xyzq":
result = loser.guess(letter)
print(f"\nLoser status: {loser.status.value}, word was: {loser.reveal_answer()!r}")
# Session: two wins then a loss — streak should reset, best_streak = 2.
alice = Player("Alice")
words = ["cat", "dog", "zebra"]
idx = {"i": 0}
class _SeqProvider(WordProvider):
def next(self) -> str:
w = words[idx["i"]]
idx["i"] += 1
return w
session = HangmanSession(alice, _SeqProvider(), max_wrong=6)
# Win 'cat' — guess c, a, t.
session.start()
for ch in "cat":
session.guess(ch)
# Win 'dog' — guess d, o, g.
session.start()
for ch in "dog":
session.guess(ch)
# Lose 'zebra' — 6 wrong guesses from letters not in "zebra".
session.start()
for ch in "fhijkl":
session.guess(ch)
print(f"\nAlice stats: wins={alice.stats.wins}, losses={alice.stats.losses}, "
f"streak={alice.stats.current_streak}, best={alice.stats.best_streak}")
assert alice.stats.wins == 2 and alice.stats.losses == 1
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.