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
1 from __future__ import annotations
2 from abc import ABC, abstractmethod
3 from dataclasses import dataclass, field
4
5
6 PINS_PER_FRAME = 10
7 FRAMES_PER_GAME = 10
8
9
10 class Frame(ABC):
11 """Base frame. Tracks rolls and exposes strike/spare queries."""
12
13 def __init__(self):
14 self.rolls: list[int] = []
15
16 @abstractmethod
17 def add_roll(self, pins: int) -> None: ...
18
19 @abstractmethod
20 def is_complete(self) -> bool: ...
21
22 def is_strike(self) -> bool:
23 return len(self.rolls) >= 1 and self.rolls[0] == PINS_PER_FRAME
24
25 def is_spare(self) -> bool:
26 return (
27 len(self.rolls) >= 2
28 and not self.is_strike()
29 and self.rolls[0] + self.rolls[1] == PINS_PER_FRAME
30 )
31
32 def pins_total(self) -> int:
33 return sum(self.rolls)
34
35
36 class NormalFrame(Frame):
37 """Frames 1-9. A strike ends the frame immediately."""
38
39 def add_roll(self, pins: int) -> None:
40 self._validate(pins)
41 self.rolls.append(pins)
42
43 def _validate(self, pins: int) -> None:
44 if not 0 <= pins <= PINS_PER_FRAME:
45 raise ValueError(f"pins must be 0..{PINS_PER_FRAME}, got {pins}")
46 if self.is_complete():
47 raise RuntimeError("Frame is already complete")
48 if len(self.rolls) == 1 and self.rolls[0] + pins > PINS_PER_FRAME:
49 raise ValueError("Two rolls cannot exceed 10 pins")
50
51 def is_complete(self) -> bool:
52 if self.is_strike():
53 return True
54 return len(self.rolls) == 2
55
56
57 class TenthFrame(Frame):
58 """Tenth frame. Up to 3 rolls if strike or spare; pin reset on first strike."""
59
60 def add_roll(self, pins: int) -> None:
61 if not 0 <= pins <= PINS_PER_FRAME:
62 raise ValueError(f"pins must be 0..{PINS_PER_FRAME}, got {pins}")
63 if self.is_complete():
64 raise RuntimeError("Frame is already complete")
65
66 # After first roll, the second roll constraint depends on strike.
67 if len(self.rolls) == 1 and self.rolls[0] != PINS_PER_FRAME:
68 if self.rolls[0] + pins > PINS_PER_FRAME:
69 raise ValueError("Two rolls cannot exceed 10 pins before the bonus")
70 # Third roll exists only if strike or spare.
71 self.rolls.append(pins)
72
73 def is_complete(self) -> bool:
74 # Three rolls always completes the tenth.
75 if len(self.rolls) == 3:
76 return True
77 # Two rolls complete only when no bonus earned.
78 if len(self.rolls) == 2:
79 return self.rolls[0] + self.rolls[1] < PINS_PER_FRAME
80 return False
81
82
83 class ScoreCalculator:
84 """Walks the flattened roll stream, applying strike/spare bonuses."""
85
86 def score(self, frames: list[Frame]) -> int:
87 # Flatten rolls with an index per frame so bonus lookups are easy.
88 frame_starts: list[int] = []
89 flat: list[int] = []
90 for f in frames:
91 frame_starts.append(len(flat))
92 flat.extend(f.rolls)
93
94 total = 0
95 for i, frame in enumerate(frames[:FRAMES_PER_GAME]):
96 start = frame_starts[i]
97 if not frame.rolls:
98 break
99 if i < FRAMES_PER_GAME - 1 and isinstance(frame, NormalFrame):
100 if frame.is_strike():
101 # Strike: 10 + next two rolls (wherever they live).
102 bonus = flat[start + 1:start + 3] if start + 3 <= len(flat) else flat[start + 1:]
103 if len(bonus) < 2:
104 break # score is partial — future rolls not yet thrown
105 total += PINS_PER_FRAME + sum(bonus)
106 elif frame.is_spare():
107 if start + 2 >= len(flat):
108 break
109 total += PINS_PER_FRAME + flat[start + 2]
110 else:
111 total += frame.pins_total()
112 else:
113 # TenthFrame or partial last frame. Sum its own rolls.
114 total += frame.pins_total()
115 return total
116
117
118 class Game:
119 def __init__(self):
120 self.frames: list[Frame] = [NormalFrame() for _ in range(FRAMES_PER_GAME - 1)] + [TenthFrame()]
121 self._current = 0
122 self._calc = ScoreCalculator()
123
124 def roll(self, pins: int) -> None:
125 if self.is_complete():
126 raise RuntimeError("Game already complete")
127 frame = self.frames[self._current]
128 frame.add_roll(pins)
129 if frame.is_complete() and self._current < FRAMES_PER_GAME - 1:
130 self._current += 1
131
132 def score(self) -> int:
133 return self._calc.score(self.frames)
134
135 def is_complete(self) -> bool:
136 return self.frames[-1].is_complete()
137
138 def frames_completed(self) -> int:
139 return sum(1 for f in self.frames if f.is_complete())
140
141
142 @dataclass
143 class Player:
144 id: str
145 name: str
146 game: Game = field(default_factory=Game)
147
148
149 class Match:
150 """Multi-player alley. Players roll in turn; rotate after a completed frame.
151 In the tenth frame the active player throws all remaining balls before rotating."""
152
153 def __init__(self, players: list[Player]):
154 if not players:
155 raise ValueError("need at least one player")
156 self._players = players
157 self._turn_idx = 0
158
159 def current_player(self) -> Player:
160 return self._players[self._turn_idx]
161
162 @property
163 def players(self) -> list[Player]:
164 return self._players
165
166 def roll(self, pins: int) -> None:
167 if self.is_complete():
168 raise RuntimeError("Match already complete")
169 player = self.current_player()
170 before = player.game.frames_completed()
171 player.game.roll(pins)
172 after = player.game.frames_completed()
173 # Rotate when the active player finished a frame. Skip over any player
174 # whose game is already complete.
175 if after > before:
176 self._advance()
177
178 def _advance(self) -> None:
179 for _ in range(len(self._players)):
180 self._turn_idx = (self._turn_idx + 1) % len(self._players)
181 if not self._players[self._turn_idx].game.is_complete():
182 return
183 # All games complete — leave index alone; is_complete() will report True.
184
185 def is_complete(self) -> bool:
186 return all(p.game.is_complete() for p in self._players)
187
188 def scores(self) -> dict[str, int]:
189 return {p.name: p.game.score() for p in self._players}
190
191 def winner(self) -> Player | None:
192 if not self.is_complete():
193 return None
194 return max(self._players, key=lambda p: p.game.score())
195
196
197 if __name__ == "__main__":
198 # Perfect game: 12 consecutive strikes = 300.
199 g = Game()
200 for _ in range(12):
201 g.roll(10)
202 assert g.is_complete(), "Perfect game should complete after 12 strikes"
203 print(f"Perfect game: {g.score()}")
204 assert g.score() == 300
205
206 # All spares of 5,5 with a final roll of 5 = 150.
207 g2 = Game()
208 for _ in range(10):
209 g2.roll(5); g2.roll(5)
210 g2.roll(5)
211 assert g2.is_complete()
212 print(f"All spares of 5: {g2.score()}")
213 assert g2.score() == 150
214
215 # Gutter game: 0 for every roll.
216 g3 = Game()
217 for _ in range(20):
218 g3.roll(0)
219 assert g3.is_complete()
220 print(f"Gutter game: {g3.score()}")
221 assert g3.score() == 0
222
223 # Mixed: 8/ then 5,4 rest = 10 + 5 (spare bonus) + 9*9 = 96
224 g4 = Game()
225 g4.roll(8); g4.roll(2) # spare
226 for _ in range(9):
227 g4.roll(5); g4.roll(4) # 9 each frame
228 print(f"Mixed game: {g4.score()}")
229 assert g4.score() == 10 + 5 + 9 * 9
230 print("All assertions passed.")
231
232 # Two-player match. Frames 1-9 alternate; frame 10 plays each game through.
233 match = Match([Player("p1", "Alice"), Player("p2", "Bob")])
234 for _ in range(9):
235 # Alice: open frame 3 + 4. Bob: strike.
236 match.roll(3); match.roll(4)
237 match.roll(10)
238 # Tenth frame: Alice open 3+4, Bob strike + strike + strike (perfect tenth).
239 match.roll(3); match.roll(4)
240 match.roll(10); match.roll(10); match.roll(10)
241 assert match.is_complete()
242 print(f"Match scores: {match.scores()}")
243 print(f"Match winner: {match.winner().name}")
244 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.