Bowling Alley
Ten frames, strikes, spares, bonus rolls. The trick is that a frame's score depends on rolls that haven't happened yet — a deferred scoring model solves it cleanly.
Key Abstractions
One turn with up to two rolls (three in the tenth). Knows its own pin state.
Ten frames plus bonus rolls. Drives frame transitions and enforces game end.
Computes running score with strike/spare bonuses. Deferred until future rolls land.
Owns a Game. Multi-player alley is a list of Players rotating turns.
Class Diagram
The Key Insight
The design question isn't "how do I score bowling" — it's "how do I score bowling when a frame's total depends on rolls that haven't been thrown yet?" A strike in frame 3 earns its bonus from rolls in frames 4 and 5. Calculating the score eagerly is impossible.
The clean move is deferred scoring. Frames just record their rolls. The ScoreCalculator walks the roll stream and computes bonuses by looking ahead. A partial game always has a valid partial score — "all rolls up to what we can see" — without any special-case logic.
The tenth frame is the other trap. Every bowling design that tries to handle it inside the generic frame class drowns in conditionals. Making TenthFrame a separate subclass with its own completion rule eliminates every "if last frame" check elsewhere.
Requirements
Functional
- Record up to 20 rolls (more if strikes/spares land in the tenth frame)
- Calculate running and final scores with strike/spare bonuses
- Handle the tenth-frame bonus rules (up to 3 rolls)
- Validate input: pin counts, frame completion, game completion
- Support multiple players taking turns on one alley
Non-Functional
- Partial scores must always be valid, even mid-game
- Score calculation must be pure — no mutation of frames
- Frame logic and score logic live in separate classes so scoring can evolve
Design Decisions
Why subclass TenthFrame instead of flagging isLast on NormalFrame?
Every time a method checks if (isLast) the code branches on position. Separate classes let each frame own its rules. Polymorphic dispatch replaces the conditional. Adding a practice frame or a team-play variant becomes a new subclass.
Why defer scoring into ScoreCalculator?
Frames know about their own rolls. The bonus rules cross frame boundaries — a strike in frame N needs rolls from frames N+1 and N+2. Putting that logic inside Frame.score() forces the frame to hold references to its neighbors. ScoreCalculator takes the full list, walks left-to-right, and pulls the bonuses forward with no back-pointers needed.
Why validate rolls[0] + rolls[1] <= 10 explicitly?
Input validation is the interviewer's hook. A frame with rolls [6, 6] is impossible — there aren't twelve pins. Catching that at addRoll() rather than at score time means bad input fails loudly at the source.
Why not calculate score per roll?
A per-roll score update means every roll has to update the previous strike frame, the one before it (if double), and the current frame. It's subtle and easy to get wrong. Recalculating from scratch on each score() call is simple, correct, and fast — the whole flattened list is 21 integers.
Interview Follow-ups
- "How would handicaps work?" Decorate the score calculator:
HandicapCalculator(base, bonus)that adds a fixed bonus per player. The player object can carry their handicap. - "What about league mode with multiple games?" A
Seriesaggregates NGameinstances per player. Running totals update after each completed game. - "How do you display a running scorecard?" Expose
frameScore(i)onScoreCalculatorreturningOptional<Integer>— empty when bonuses haven't landed, present once resolved. UI renders "-" for empty. - "How would you test the perfect game?"
for _ in range(12) g.roll(10), assert score == 300. That single test covers the strike-bonus propagation through the tenth frame.
Code Implementation
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
PINS_PER_FRAME = 10
FRAMES_PER_GAME = 10
class Frame(ABC):
"""Base frame. Tracks rolls and exposes strike/spare queries."""
def __init__(self):
self.rolls: list[int] = []
@abstractmethod
def add_roll(self, pins: int) -> None: ...
@abstractmethod
def is_complete(self) -> bool: ...
def is_strike(self) -> bool:
return len(self.rolls) >= 1 and self.rolls[0] == PINS_PER_FRAME
def is_spare(self) -> bool:
return (
len(self.rolls) >= 2
and not self.is_strike()
and self.rolls[0] + self.rolls[1] == PINS_PER_FRAME
)
def pins_total(self) -> int:
return sum(self.rolls)
class NormalFrame(Frame):
"""Frames 1-9. A strike ends the frame immediately."""
def add_roll(self, pins: int) -> None:
self._validate(pins)
self.rolls.append(pins)
def _validate(self, pins: int) -> None:
if not 0 <= pins <= PINS_PER_FRAME:
raise ValueError(f"pins must be 0..{PINS_PER_FRAME}, got {pins}")
if self.is_complete():
raise RuntimeError("Frame is already complete")
if len(self.rolls) == 1 and self.rolls[0] + pins > PINS_PER_FRAME:
raise ValueError("Two rolls cannot exceed 10 pins")
def is_complete(self) -> bool:
if self.is_strike():
return True
return len(self.rolls) == 2
class TenthFrame(Frame):
"""Tenth frame. Up to 3 rolls if strike or spare; pin reset on first strike."""
def add_roll(self, pins: int) -> None:
if not 0 <= pins <= PINS_PER_FRAME:
raise ValueError(f"pins must be 0..{PINS_PER_FRAME}, got {pins}")
if self.is_complete():
raise RuntimeError("Frame is already complete")
# After first roll, the second roll constraint depends on strike.
if len(self.rolls) == 1 and self.rolls[0] != PINS_PER_FRAME:
if self.rolls[0] + pins > PINS_PER_FRAME:
raise ValueError("Two rolls cannot exceed 10 pins before the bonus")
# Third roll exists only if strike or spare.
self.rolls.append(pins)
def is_complete(self) -> bool:
# Three rolls always completes the tenth.
if len(self.rolls) == 3:
return True
# Two rolls complete only when no bonus earned.
if len(self.rolls) == 2:
return self.rolls[0] + self.rolls[1] < PINS_PER_FRAME
return False
class ScoreCalculator:
"""Walks the flattened roll stream, applying strike/spare bonuses."""
def score(self, frames: list[Frame]) -> int:
# Flatten rolls with an index per frame so bonus lookups are easy.
frame_starts: list[int] = []
flat: list[int] = []
for f in frames:
frame_starts.append(len(flat))
flat.extend(f.rolls)
total = 0
for i, frame in enumerate(frames[:FRAMES_PER_GAME]):
start = frame_starts[i]
if not frame.rolls:
break
if i < FRAMES_PER_GAME - 1 and isinstance(frame, NormalFrame):
if frame.is_strike():
# Strike: 10 + next two rolls (wherever they live).
bonus = flat[start + 1:start + 3] if start + 3 <= len(flat) else flat[start + 1:]
if len(bonus) < 2:
break # score is partial — future rolls not yet thrown
total += PINS_PER_FRAME + sum(bonus)
elif frame.is_spare():
if start + 2 >= len(flat):
break
total += PINS_PER_FRAME + flat[start + 2]
else:
total += frame.pins_total()
else:
# TenthFrame or partial last frame. Sum its own rolls.
total += frame.pins_total()
return total
class Game:
def __init__(self):
self.frames: list[Frame] = [NormalFrame() for _ in range(FRAMES_PER_GAME - 1)] + [TenthFrame()]
self._current = 0
self._calc = ScoreCalculator()
def roll(self, pins: int) -> None:
if self.is_complete():
raise RuntimeError("Game already complete")
frame = self.frames[self._current]
frame.add_roll(pins)
if frame.is_complete() and self._current < FRAMES_PER_GAME - 1:
self._current += 1
def score(self) -> int:
return self._calc.score(self.frames)
def is_complete(self) -> bool:
return self.frames[-1].is_complete()
def frames_completed(self) -> int:
return sum(1 for f in self.frames if f.is_complete())
@dataclass
class Player:
id: str
name: str
game: Game = field(default_factory=Game)
class Match:
"""Multi-player alley. Players roll in turn; rotate after a completed frame.
In the tenth frame the active player throws all remaining balls before rotating."""
def __init__(self, players: list[Player]):
if not players:
raise ValueError("need at least one player")
self._players = players
self._turn_idx = 0
def current_player(self) -> Player:
return self._players[self._turn_idx]
@property
def players(self) -> list[Player]:
return self._players
def roll(self, pins: int) -> None:
if self.is_complete():
raise RuntimeError("Match already complete")
player = self.current_player()
before = player.game.frames_completed()
player.game.roll(pins)
after = player.game.frames_completed()
# Rotate when the active player finished a frame. Skip over any player
# whose game is already complete.
if after > before:
self._advance()
def _advance(self) -> None:
for _ in range(len(self._players)):
self._turn_idx = (self._turn_idx + 1) % len(self._players)
if not self._players[self._turn_idx].game.is_complete():
return
# All games complete — leave index alone; is_complete() will report True.
def is_complete(self) -> bool:
return all(p.game.is_complete() for p in self._players)
def scores(self) -> dict[str, int]:
return {p.name: p.game.score() for p in self._players}
def winner(self) -> Player | None:
if not self.is_complete():
return None
return max(self._players, key=lambda p: p.game.score())
if __name__ == "__main__":
# Perfect game: 12 consecutive strikes = 300.
g = Game()
for _ in range(12):
g.roll(10)
assert g.is_complete(), "Perfect game should complete after 12 strikes"
print(f"Perfect game: {g.score()}")
assert g.score() == 300
# All spares of 5,5 with a final roll of 5 = 150.
g2 = Game()
for _ in range(10):
g2.roll(5); g2.roll(5)
g2.roll(5)
assert g2.is_complete()
print(f"All spares of 5: {g2.score()}")
assert g2.score() == 150
# Gutter game: 0 for every roll.
g3 = Game()
for _ in range(20):
g3.roll(0)
assert g3.is_complete()
print(f"Gutter game: {g3.score()}")
assert g3.score() == 0
# Mixed: 8/ then 5,4 rest = 10 + 5 (spare bonus) + 9*9 = 96
g4 = Game()
g4.roll(8); g4.roll(2) # spare
for _ in range(9):
g4.roll(5); g4.roll(4) # 9 each frame
print(f"Mixed game: {g4.score()}")
assert g4.score() == 10 + 5 + 9 * 9
print("All assertions passed.")
# Two-player match. Frames 1-9 alternate; frame 10 plays each game through.
match = Match([Player("p1", "Alice"), Player("p2", "Bob")])
for _ in range(9):
# Alice: open frame 3 + 4. Bob: strike.
match.roll(3); match.roll(4)
match.roll(10)
# Tenth frame: Alice open 3+4, Bob strike + strike + strike (perfect tenth).
match.roll(3); match.roll(4)
match.roll(10); match.roll(10); match.roll(10)
assert match.is_complete()
print(f"Match scores: {match.scores()}")
print(f"Match winner: {match.winner().name}")
assert match.winner().name == "Bob"Common Mistakes
- ✗Calculating score inside Frame.addRoll(). Future rolls haven't happened yet; the score is a lie.
- ✗Hardcoding the tenth-frame rules inside Game. State pattern for frames keeps the tenth frame isolated.
- ✗Forgetting the perfect game bonus. 12 strikes, not 10. The tenth frame demands two more rolls on a strike.
- ✗Counting a strike as two rolls. It's one roll for ten pins — the bonus is not the same as a second roll.
Key Points
- ✓Strike: 10 pins on first roll. Bonus = next two rolls. Frame ends immediately.
- ✓Spare: 10 pins across two rolls. Bonus = next one roll. Frame ends after second roll.
- ✓Tenth frame is special — a strike or spare earns one or two bonus rolls inside the same frame.
- ✓ScoreCalculator reads all frames and walks the roll stream. Calculating partial scores is always safe.