Design a bowling alley¶
TL;DR¶
- You build two things that meet at one point: a house that hands out lanes, and a game that scores ten frames per player on the lane it was given.
- Three decisions carry the interview: the tenth frame is the same
Frameclass with a flag (its rules live inremaining_pinsandis_complete, nowhere else), the card is recomputed from the rolls on every read so a bonus that arrives two frames later needs no cache invalidation, and turn rotation is a hook override — the ball passes when a frame closes, not after every ball. - Patterns that earn their place: Template Method (the
BoardGamebase from tic-tac-toe), State, Strategy (scoring, pricing), Observer (scoreboard), Object Pool (lanes). Singleton is discussed and deliberately not used.
Problem statement¶
"Design the software for a bowling alley. Customers reserve a lane for a party of one to six, optionally renting shoes, and are charged for the games they play. On the lane they bowl ten frames each, taking turns. A strike is worth ten plus the next two balls, a spare ten plus the next one, and the tenth frame gets up to three balls. A screen above the lane shows live scores. Show me the classes, how a frame is scored while its bonus is still unknown, and what happens when two receptionists book the last lane at the same moment."
Requirements¶
Functional
- Lanes with availability; reserve one, play on it, release it back.
- One to six players per game, rotating turns.
- Ten frames per player, with strike, spare and open frames, and up to three balls in the tenth.
- Standard cumulative scoring, including totals that are still provisional while bonuses are unresolved.
- A live scoreboard that is told about changes rather than polling.
- Completion detection and a winner, with a tie reported as a draw.
- Pricing per player per game, plus optional shoe rental, with an alternative discount policy.
Non-functional and constraints
- Correct under concurrency: two receptionists must never be given the same lane.
- Pin counts are validated against the pins actually standing, including after a tenth-frame re-rack.
- Money is integer cents, never a float. Time and ids are injected.
- In-memory, single process, standard library only.
Out of scope: pin-setter hardware, food and drink orders, leagues and handicaps, multiple branches.
Clarifying questions and assumptions¶
| Question to ask | Assumption taken here |
|---|---|
| Is the tenth frame a special class? | No — a flag on Frame. The rules differ, the interface does not, so the game never asks which frame it is on. |
| When does the ball pass to the next player? | When the current frame closes: after one ball for a strike, two for an open frame, up to three in the tenth. |
| What total does the board show while a strike is unresolved? | The provisional one, with the bonus counted as zero, flagged so the display can mark it. That is what a real scoreboard does. |
| Who supplies the pin count? | The caller — a pin-setter, an operator, or a test. roll(player, pins) is the whole input surface. |
| Do players bowl several games per booking? | The booking records how many and prices them; the model here runs one BowlingGame object per game. |
| Is the alley a Singleton? | No. It is built once in main and injected, exactly as with the parking lot — a second branch is then a second object. |
| What if two players tie? | winner() returns None, which the shared base reports as a draw. |
Core entities and relationships¶
- BowlingAlley — the house. It owns the lanes, the bookings and the lock, and it is the pool:
reserveacquires a lane,finishreturns it. - Lane with LaneStatus — free, reserved, in play, under maintenance, with guarded transitions so a lane cannot be handed out twice.
- Booking — a frozen record: lane, players, games, shoes, price and the injected timestamp.
- PricingStrategy —
PerGamePricingandHappyHourPricing, which wraps any other policy rather than subclassing it. - BowlingGame — a
BoardGamesubclass. It holds one card of tenFrames per player, the shared frame index, and the turn rotation rule. - Frame and Roll — a frame owns its rolls and every rule about how many it may take.
is_lastis the only thing that makes the tenth different. - ScoreCalculator with StandardScoring — turns a card of frames into a card of scores.
- FrameScore and FrameStatus — one cell of the scorecard and whether its total can still move.
- Standing and Scoreboard — a row of the live board, and the observer that renders it.
Multiplicities: alley 1 -> * lanes, alley 1 -> * bookings, booking 1 -> 1 lane, game 1 -> 1..6 players, player 1 -> 10 frames, frame 1 -> 1..3 rolls, game 1 -> * observers.
Class diagram¶
The game: ten frames per player, and a calculator that reads them.
classDiagram
class BoardGame {
<<abstract>>
+play_turn()
+advance_turn()
+choose_move(player) M
+apply_move(player, move)
+is_over() bool
+winner() str
}
class BowlingGame {
+roll(player, pins) FrameScore
+current_frame(player) Frame
+scorecard(player) List~FrameScore~
+total(player) int
+standings() tuple
}
class Frame {
+number int
+is_last bool
+add(pins) Roll
+remaining_pins() int
+is_complete() bool
+frame_type FrameType
+marks() str
}
class Roll {
+number int
+pins int
}
class FrameScore {
+number int
+status FrameStatus
+pins int
+bonus int
+running_total int
+final bool
}
class Standing {
+player str
+frame int
+total int
+card str
}
class ScoreCalculator {
<<interface>>
+score(frames) List~FrameScore~
}
class StandardScoring
class Scoreboard {
+rows() tuple
+render() str
}
BoardGame <|-- BowlingGame
BowlingGame "1" o-- "many" Frame
Frame "1" *-- "many" Roll
BowlingGame --> ScoreCalculator
ScoreCalculator <|.. StandardScoring
ScoreCalculator ..> FrameScore : produces
BowlingGame ..> Standing : publishes
Scoreboard --> BowlingGame : observes
The house: a pool of lanes, a booking, and the price of it.
classDiagram
class BowlingAlley {
+name str
+reserve(players, games, shoes) Booking
+start_game(booking_id) BowlingGame
+finish(booking_id)
+free_lanes() int
+take_out_of_service(lane_id)
}
class Lane {
+id str
+status LaneStatus
+reserve(booking_id)
+start_play()
+release()
+take_out_of_service()
}
class Booking {
+id str
+lane_id str
+players tuple
+games int
+shoes int
+price Money
}
class PricingStrategy {
<<interface>>
+quote(booking) Money
}
class PerGamePricing
class HappyHourPricing
class LaneStatus {
<<enumeration>>
FREE
RESERVED
IN_PLAY
MAINTENANCE
}
class BowlingGame
BowlingAlley "1" *-- "many" Lane
BowlingAlley "1" o-- "many" Booking
BowlingAlley --> PricingStrategy
BowlingAlley ..> BowlingGame : starts
Lane --> LaneStatus
Booking --> Lane
PricingStrategy <|.. PerGamePricing
PricingStrategy <|.. HappyHourPricing
HappyHourPricing o-- PricingStrategy : wraps
Design patterns applied¶
| Pattern | Where | Why it earns its place |
|---|---|---|
| Template Method | BoardGame from the tic-tac-toe package |
Bowling is a turn-based game with an unusual rotation. Reusing the skeleton and overriding one hook is a far better answer than a second bespoke turn loop, and it is a concrete demonstration that the abstraction was worth building. |
| State | FrameStatus and LaneStatus with guarded transitions |
Two lifecycles, both with four states, both with illegal edges that must raise rather than silently pass. Here enums plus guard clauses are enough because the transitions are guarded in one place each; the snake game is where per-state behaviour justifies actual State classes. |
| Strategy | ScoreCalculator, PricingStrategy |
Scoring rules vary by league, prices vary by hour. HappyHourPricing wraps another policy rather than subclassing it, so discounts compose. |
| Observer | Scoreboard and GameLog on the same game |
The game emits events and knows nothing about screens. The board renders the latest standings; the log keeps the history. Two observers, two jobs. |
| Object Pool | BowlingAlley as the pool of Lanes |
Lanes are the scarce, reusable resource. reserve is acquire, finish is release, and the pool is exhausted rather than grown. Say the pattern name and then say it has a domain name here, which is why there is no class called LanePool. |
| Dependency injection | Clock, IdGenerator, both strategies |
Bookings are timestamped from an injected clock and identified by an injected generator, so the demo and the tests are byte-for-byte reproducible. |
What was deliberately not used: Singleton for BowlingAlley, for the same reason as in the parking lot — one instance created in main and injected lets tests build a dozen alleys. Also not used: a TenthFrame subclass. It sounds right and it is a trap: Game would have to know which frame it is holding, and every method would need an isinstance check or a duplicated implementation. One flag, two branches inside Frame, and the interface stays uniform.
Key flows¶
From the front desk to a scored ball.
sequenceDiagram
autonumber
actor R as Receptionist
participant A as BowlingAlley
participant L as Lane
participant G as BowlingGame
participant F as Frame
participant C as StandardScoring
participant B as Scoreboard
R->>A: reserve(Ana and Bo, 1 game, 2 pairs of shoes)
A->>A: lock, then find the first free lane
A->>L: reserve(booking_id)
A-->>R: Booking, priced
R->>A: start_game(booking_id)
A->>L: start_play()
A-->>R: BowlingGame
R->>G: roll("Ana", 7)
G->>G: require_turn("Ana")
G->>F: add(7), validated against the standing pins
G->>G: advance_turn only if the frame closed
G->>C: score(frames)
C-->>G: card with provisional totals
G-)B: on_event, buffered then flushed
G-->>R: FrameScore
A frame's score. The edge that matters is AWAITING_BONUS -> SCORED: it is triggered by a ball thrown in a later frame, which is why the calculator reads the whole card rather than each frame in isolation.
stateDiagram-v2
[*] --> EMPTY
EMPTY --> IN_PROGRESS : first ball leaves pins standing
EMPTY --> AWAITING_BONUS : strike
IN_PROGRESS --> AWAITING_BONUS : spare
IN_PROGRESS --> SCORED : open frame closes
AWAITING_BONUS --> SCORED : the bonus balls are thrown
SCORED --> [*]
A lane's life. Every edge not drawn here raises LaneUnavailableError, which is what makes double booking impossible rather than unlikely.
stateDiagram-v2
[*] --> FREE
FREE --> RESERVED : reserve
RESERVED --> IN_PLAY : start_play
RESERVED --> FREE : release
IN_PLAY --> FREE : finish
FREE --> MAINTENANCE : take_out_of_service
RESERVED --> MAINTENANCE : take_out_of_service
MAINTENANCE --> FREE : return_to_service
Implementation¶
Write the frame first. Everything else in this problem is easy, and everything an interviewer probes is in those forty lines.
The vocabulary: two enums for the two lifecycles, and errors that subclass the shared hierarchy:
class FrameType(StrEnum):
INCOMPLETE = "incomplete"
STRIKE = "strike" # all ten with the first ball
SPARE = "spare" # all ten with two balls
OPEN = "open" # pins left standing
class FrameStatus(StrEnum):
"""A frame's *score* lifecycle, which is what a scoreboard renders."""
EMPTY = "empty"
IN_PROGRESS = "in_progress"
AWAITING_BONUS = "awaiting_bonus" # complete, but the total still depends on later rolls
SCORED = "scored"
class LaneStatus(StrEnum):
FREE = "free"
RESERVED = "reserved"
IN_PLAY = "in_play"
MAINTENANCE = "maintenance"
class InvalidPinCountError(ValidationError):
"""More pins knocked down than are standing, or a negative count."""
class FrameCompleteError(InvalidStateError):
"""The frame has taken all the rolls it is allowed."""
class LaneUnavailableError(ConflictError):
"""No lane is free, or this lane is not in a state that allows the operation."""
class BookingNotFoundError(NotFoundError):
"""Unknown booking id."""
Frame is the heart of the problem. remaining_pins re-racks after a strike or a spare in the tenth, which makes pin validation a single expression everywhere; is_complete encodes "up to three balls, but only if you earned the third":
@dataclass(frozen=True, slots=True)
class Roll:
number: int # 1-based within the frame
pins: int
@dataclass(slots=True)
class Frame:
"""One frame of one player's card. The tenth frame is the same class with a flag.
Keeping the last frame here rather than in a subclass means the *rules* differ by
a flag while the *interface* does not, so ``Game`` never asks which frame it is on.
"""
number: int
is_last: bool = False
rolls: list[Roll] = field(default_factory=list)
def pins_knocked(self) -> int:
return sum(roll.pins for roll in self.rolls)
def remaining_pins(self) -> int:
"""Pins still standing. In the tenth frame the rack resets after a strike or a spare."""
if not self.is_last:
return PINS - self.pins_knocked()
standing = PINS
for roll in self.rolls:
standing -= roll.pins
if standing == 0:
standing = PINS
return standing
def is_complete(self) -> bool:
if not self.is_last:
return len(self.rolls) == 2 or (bool(self.rolls) and self.rolls[0].pins == PINS)
if len(self.rolls) < 2:
return False
earned_bonus = self.rolls[0].pins + self.rolls[1].pins >= PINS
return len(self.rolls) == 3 if earned_bonus else True
@property
def frame_type(self) -> FrameType:
if not self.is_complete():
return FrameType.INCOMPLETE
if self.rolls[0].pins == PINS:
return FrameType.STRIKE
if self.rolls[0].pins + self.rolls[1].pins == PINS:
return FrameType.SPARE
return FrameType.OPEN
def add(self, pins: int) -> Roll:
"""Validate against the pins actually standing, then record the roll."""
if self.is_complete():
raise FrameCompleteError(f"frame {self.number} is already complete")
if not 0 <= pins <= self.remaining_pins():
raise InvalidPinCountError(
f"frame {self.number}: {pins} pins, but {self.remaining_pins()} are standing"
)
roll = Roll(len(self.rolls) + 1, pins)
self.rolls.append(roll)
return roll
def marks(self) -> str:
"""The card notation a scoreboard prints: X for a strike, / for a spare, - for a gutter."""
out: list[str] = []
standing, fresh = PINS, True
for roll in self.rolls:
if roll.pins == PINS and fresh:
out.append("X") # a full rack cleared with one ball
elif roll.pins == standing:
out.append("/") # the rest of the rack cleared: a spare
elif roll.pins == 0:
out.append("-")
else:
out.append(str(roll.pins))
standing -= roll.pins
fresh = standing == 0 # the tenth frame re-racks, and the next ball is a fresh one
if standing == 0:
standing = PINS
return "".join(out)
The scorecard types are frozen. FrameScore.status is what a display uses to decide whether to print a total or a total with a mark next to it:
@dataclass(frozen=True, slots=True)
class FrameScore:
"""One cell of the scorecard. ``status`` says whether the total can still move."""
number: int
frame_type: FrameType
status: FrameStatus
pins: int
bonus: int
running_total: int
@property
def final(self) -> bool:
return self.status is FrameStatus.SCORED
@dataclass(frozen=True, slots=True)
class Standing:
"""One row of the live scoreboard."""
player: str
frame: int
total: int
final: bool
card: str
Lanes and bookings. Every lane transition is guarded, so an illegal one raises instead of quietly corrupting availability:
@dataclass(slots=True)
class Lane:
"""A physical lane. The status transitions are guarded so a lane cannot be double booked."""
id: str
status: LaneStatus = LaneStatus.FREE
booking_id: str | None = None
def is_free(self) -> bool:
return self.status is LaneStatus.FREE
def reserve(self, booking_id: str) -> None:
if not self.is_free():
raise LaneUnavailableError(f"lane {self.id} is {self.status}")
self.status = LaneStatus.RESERVED
self.booking_id = booking_id
def start_play(self) -> None:
if self.status is not LaneStatus.RESERVED:
raise LaneUnavailableError(f"lane {self.id} is {self.status}, not reserved")
self.status = LaneStatus.IN_PLAY
def release(self) -> None:
if self.status not in (LaneStatus.RESERVED, LaneStatus.IN_PLAY):
raise LaneUnavailableError(f"lane {self.id} is {self.status}")
self.status = LaneStatus.FREE
self.booking_id = None
def take_out_of_service(self) -> None:
if self.status is LaneStatus.IN_PLAY:
raise LaneUnavailableError(f"lane {self.id} has a game on it")
self.status = LaneStatus.MAINTENANCE
self.booking_id = None
def return_to_service(self) -> None:
if self.status is not LaneStatus.MAINTENANCE:
raise LaneUnavailableError(f"lane {self.id} is not under maintenance")
self.status = LaneStatus.FREE
@dataclass(frozen=True, slots=True)
class Booking:
id: str
lane_id: str
players: tuple[str, ...]
games: int
shoes: int
price: Money
created_at: float
Scoring recomputes the whole card from the roll list on every call. That is at most 21 numbers per player, so there is no incremental cache to invalidate and a bonus arriving two frames later is picked up for free:
class ScoreCalculator(Protocol):
"""Turns a card of frames into a card of scores. Stateless, so it is thread-safe."""
def score(self, frames: Sequence[Frame]) -> list[FrameScore]: ...
class StandardScoring:
"""Ten-pin scoring: a strike is worth 10 plus the next two balls, a spare 10 plus one.
The whole card is recomputed from the roll list on every call. That is O(rolls) -
at most 21 numbers - so there is no incremental cache to invalidate and a bonus
that arrives three frames later is picked up automatically. A frame whose bonus
balls have not been thrown yet is reported as ``AWAITING_BONUS`` with the bonus
counted as zero, which is exactly the provisional total a real scoreboard shows.
"""
def score(self, frames: Sequence[Frame]) -> list[FrameScore]:
pins = [roll.pins for frame in frames for roll in frame.rolls]
card: list[FrameScore] = []
index = 0
running = 0
for frame in frames:
thrown = len(frame.rolls)
if thrown == 0:
card.append(FrameScore(frame.number, FrameType.INCOMPLETE, FrameStatus.EMPTY, 0, 0, running))
continue
knocked, bonus, resolved = self._value(frame, pins, index)
running += knocked + bonus
card.append(
FrameScore(
number=frame.number,
frame_type=frame.frame_type,
status=self._status(frame, resolved),
pins=knocked,
bonus=bonus,
running_total=running,
)
)
index += thrown
return card
@staticmethod
def _value(frame: Frame, pins: list[int], index: int) -> tuple[int, int, bool]:
"""Returns (pins knocked in the frame, bonus, whether the bonus is fully thrown)."""
if frame.is_last: # the bonus balls are part of the tenth frame itself
return frame.pins_knocked(), 0, frame.is_complete()
if frame.rolls[0].pins == PINS:
extra = pins[index + 1 : index + 3]
return PINS, sum(extra), len(extra) == 2
if frame.is_complete() and frame.pins_knocked() == PINS:
extra = pins[index + 2 : index + 3]
return PINS, sum(extra), len(extra) == 1
return frame.pins_knocked(), 0, frame.is_complete()
@staticmethod
def _status(frame: Frame, resolved: bool) -> FrameStatus:
if not frame.is_complete():
return FrameStatus.IN_PROGRESS
return FrameStatus.SCORED if resolved else FrameStatus.AWAITING_BONUS
class PricingStrategy(Protocol):
"""What a booking costs. Money is integer cents, never a float."""
def quote(self, booking: Booking) -> Money: ...
DEFAULT_PER_GAME = Money.of("6.50")
DEFAULT_SHOE_RENTAL = Money.of("3.00")
class PerGamePricing:
"""The usual house rule: a price per player per game, plus shoe rental."""
def __init__(
self,
per_game: Money = DEFAULT_PER_GAME,
shoe_rental: Money = DEFAULT_SHOE_RENTAL,
) -> None:
self._per_game = per_game
self._shoe_rental = shoe_rental
def quote(self, booking: Booking) -> Money:
return self._per_game * (len(booking.players) * booking.games) + self._shoe_rental * booking.shoes
class HappyHourPricing:
"""A percentage off any other policy. Composition, so discounts stack instead of subclassing."""
def __init__(self, base: PricingStrategy, percent_off: int) -> None:
if not 0 <= percent_off <= 100:
raise ValidationError("percent_off must be between 0 and 100")
self._base = base
self._percent_off = percent_off
def quote(self, booking: Booking) -> Money:
full = self._base.quote(booking)
return Money(full.cents - full.cents * self._percent_off // 100, full.currency)
The game is short because the template already exists. Read advance_turn: three lines that encode "the ball passes when the frame closes, and the frame number moves on when the last player has bowled":
class BowlingGame(BoardGame[int]):
"""Ten-pin bowling on the shared ``BoardGame`` template, with one twist.
Tic-tac-toe and snake and ladder rotate after every move. Bowling rotates after
every *frame*, which is why ``advance_turn`` is a hook on the base class: a
strike ends your turn after one ball, an open frame after two, and the tenth
frame after three. Overriding one method buys all of that.
"""
MIN_PLAYERS = 1
MAX_PLAYERS = 6
def __init__(
self,
players: Sequence[str],
calculator: ScoreCalculator | None = None,
*,
turn_limit: int = DEFAULT_TURN_LIMIT,
) -> None:
super().__init__(players, turn_limit=turn_limit)
self._calculator = calculator or StandardScoring()
self._cards: dict[str, list[Frame]] = {p: self._new_card() for p in self.players}
self._frame_index = 0
self._pending_pins: int | None = None
@staticmethod
def _new_card() -> list[Frame]:
return [Frame(number, is_last=number == FRAMES) for number in range(1, FRAMES + 1)]
# -- the template's steps ---------------------------------------------------------
def setup(self) -> None:
self._cards = {p: self._new_card() for p in self.players}
self._frame_index = 0
def choose_move(self, player: str) -> int:
if self._pending_pins is None:
raise InvalidStateError("a bowling game is driven by roll(pins), not by a bot")
pins, self._pending_pins = self._pending_pins, None
return pins
def apply_move(self, player: str, move: int) -> None:
self.current_frame(player).add(move) # raises on an over-count or a finished frame
def after_move(self, player: str, move: int) -> None:
frame = self.current_frame(player)
self.emit(f"{player} frame {frame.number}: {frame.marks()}", actor=player)
def is_over(self) -> bool:
return all(card[FRAMES - 1].is_complete() for card in self._cards.values())
def winner(self) -> str | None:
totals = {player: self.total(player) for player in self.players}
best = max(totals.values())
leaders = [player for player, total in totals.items() if total == best]
return leaders[0] if len(leaders) == 1 else None # a tie is a draw
def advance_turn(self) -> None:
"""Hook override: the ball passes only when the roller's frame is finished."""
if not self.current_frame(self.current_player).is_complete():
return
super().advance_turn()
if self._turn_index == 0:
self._frame_index += 1
# -- what the template does not give you -------------------------------------------
def roll(self, player: str, pins: int) -> FrameScore:
"""The pin-setter's call. Turn-checked, then validated against the standing pins."""
with self._lock:
self.require_turn(player)
number = self.current_frame(player).number
self._pending_pins = pins
try:
self.play_turn()
finally:
self._pending_pins = None
return self.scorecard(player)[number - 1]
def current_frame(self, player: str) -> Frame:
return self._cards[player][self._frame_index]
def card(self, player: str) -> tuple[Frame, ...]:
with self._lock:
return tuple(self._cards[player])
def scorecard(self, player: str) -> list[FrameScore]:
with self._lock:
return self._calculator.score(self._cards[player])
def total(self, player: str) -> int:
return self.scorecard(player)[-1].running_total
def standings(self) -> tuple[Standing, ...]:
"""A consistent snapshot of every card, computed under the lock."""
with self._lock:
return tuple(
Standing(
player=player,
frame=self._frame_index + 1,
total=self.total(player),
final=all(s.status is FrameStatus.SCORED for s in self.scorecard(player)),
card=" ".join(f.marks() for f in self._cards[player] if f.rolls),
)
for player in self.players
)
class Scoreboard:
"""Observer: a *live* board. It renders the newest standings, not the ones at emit time.
That is deliberate. A scoreboard shows now; if you want the frame-by-frame history,
subscribe a ``GameLog`` to the same game. Two observers, two different jobs, and
the game knows about neither.
"""
def __init__(self, game: BowlingGame) -> None:
self._game = game
self._lock = threading.Lock()
self._rows: tuple[Standing, ...] = ()
game.subscribe(self)
def on_event(self, event: GameEvent) -> None:
rows = self._game.standings() # the newest snapshot, not the one at emit time
with self._lock:
self._rows = rows
def rows(self) -> tuple[Standing, ...]:
with self._lock:
return self._rows
def render(self) -> str:
return "\n".join(
f"{row.player:<5} frame {row.frame:>2} {row.total:>3}{' ' if row.final else '*'} {row.card}"
for row in self.rows()
)
The alley is the pool. reserve finds and claims a lane inside one lock, which is the only thing standing between you and two parties on lane 3:
class BowlingAlley:
"""The house. It owns the lanes and hands them out one at a time.
This is an Object Pool with a domain name: ``reserve`` acquires a lane, ``finish``
returns it. ``_lock`` guards the lane statuses and the booking registry, and the
race it prevents is two receptionists giving away the last lane. It is
deliberately *not* a Singleton class - it is built once in ``main`` and injected,
so tests build a dozen alleys and a second branch is a second object.
"""
def __init__(
self,
name: str,
lanes: Iterable[Lane],
pricing: PricingStrategy | None = None,
clock: Clock | None = None,
ids: IdGenerator | None = None,
) -> None:
self.name = name
self._lanes: dict[str, Lane] = {lane.id: lane for lane in lanes}
self._pricing = pricing or PerGamePricing()
self._clock = clock or SystemClock()
self._ids = ids or SequentialIdGenerator("BK")
self._bookings: dict[str, Booking] = {}
self._games: dict[str, BowlingGame] = {}
self._lock = threading.Lock()
def reserve(self, players: Sequence[str], games: int = 1, shoes: int = 0) -> Booking:
"""Claim the first free lane atomically, then price the booking."""
if not BowlingGame.MIN_PLAYERS <= len(players) <= BowlingGame.MAX_PLAYERS:
raise ValidationError(
f"a game takes {BowlingGame.MIN_PLAYERS}-{BowlingGame.MAX_PLAYERS} players, got {len(players)}"
)
if games < 1 or shoes < 0 or shoes > len(players):
raise ValidationError("games must be positive and shoes cannot exceed the party size")
with self._lock:
lane = next((candidate for candidate in self._lanes.values() if candidate.is_free()), None)
if lane is None:
raise LaneUnavailableError(f"no free lane at {self.name}")
draft = Booking(
id=self._ids.next_id(),
lane_id=lane.id,
players=tuple(players),
games=games,
shoes=shoes,
price=Money(0),
created_at=self._clock.now(),
)
booking = replace(draft, price=self._pricing.quote(draft))
lane.reserve(booking.id)
self._bookings[booking.id] = booking
return booking
def start_game(self, booking_id: str) -> BowlingGame:
with self._lock:
booking = self._booking(booking_id)
self._lanes[booking.lane_id].start_play()
game = BowlingGame(booking.players)
self._games[booking_id] = game
return game
def finish(self, booking_id: str) -> None:
"""Return the lane to the pool. Releasing an already free lane is an error, not a no-op."""
with self._lock:
booking = self._booking(booking_id)
self._lanes[booking.lane_id].release()
self._games.pop(booking_id, None)
def game(self, booking_id: str) -> BowlingGame:
with self._lock:
if booking_id not in self._games:
raise BookingNotFoundError(f"no game running for booking {booking_id}")
return self._games[booking_id]
def lane(self, lane_id: str) -> Lane:
with self._lock:
if lane_id not in self._lanes:
raise BookingNotFoundError(f"unknown lane {lane_id}")
return self._lanes[lane_id]
def free_lanes(self) -> int:
with self._lock:
return sum(1 for lane in self._lanes.values() if lane.is_free())
def take_out_of_service(self, lane_id: str) -> None:
with self._lock:
self._lanes[lane_id].take_out_of_service()
def reprice(self, booking: Booking) -> Booking:
"""Re-quote an existing booking, for example after a happy-hour rule is swapped in."""
return replace(booking, price=self._pricing.quote(booking))
def _booking(self, booking_id: str) -> Booking:
if booking_id not in self._bookings:
raise BookingNotFoundError(f"unknown booking {booking_id}")
return self._bookings[booking_id]
Running python -m lld.bowling_alley.demo books a lane, bowls the textbook 133 card against a perfect game, and shows the provisional totals settling:
--- Sunset Lanes: BK-1 on L1, 2 lanes still free ---
price 19.00 USD for 2 players x 1 game plus 2 pairs of shoes; happy hour 15.20 USD
--- after four frames, a star marks a total that later balls can still move ---
Ana frame 5 39* 14 45 6/ 5/
Bo frame 5 90* X X X X
--- final cards ---
Ana frame 10 133 14 45 6/ 5/ X -1 7/ 6/ X 2/6
Bo frame 10 300 X X X X X X X X X XXX
won: Bo beats Ana 300 to 133 in 31 rolls
tenth frame: Ana threw 3, Bo threw 3
--- rejections ---
too many pins: InvalidPinCountError: frame 1: 5 pins, but 3 are standing
game over: InvalidStateError: game is won; no turn can be played
lane L1 is free, 3 lanes free
Point at the middle block in the interview. After four frames Ana's total reads 39 with a star: her fourth-frame spare is worth ten plus a ball she has not thrown yet. Bo's 90 is provisional for the same reason across three of his four strikes. Both settle — to 133 and to the perfect 300 — without a single cache being invalidated.
Concurrency and edge cases¶
Which lock protects what. There are two, and they never nest.
BowlingAlley._lockguards the lane statuses and the booking registry.reservescans for a free lane and claims it inside the lock, so the scan cannot go stale between reading and writing. Two receptionists racing for the last lane serialise here and the loser getsLaneUnavailableError. The concurrency test fires 30 bookings from eight workers at four lanes and asserts that exactly four succeed with four distinct lane ids.BoardGame._lock, inherited byBowlingGame, guards one game's cards and turn cursor. A game is a small aggregate whose parts change together, so the game object is the unit of locking — the same argument as in tic-tac-toe, and the opposite granularity from the parking lot, where floors are independent.
A lane and a game are never locked at the same time: start_game builds the game while holding the alley lock, but the game is not yet visible to anyone else, and every later roll touches only the game lock. Saying that out loud is what shows you thought about lock ordering rather than got lucky.
Provisional scores. A strike or a spare is complete as a frame but not as a score. The calculator reports it as AWAITING_BONUS with the bonus counted as zero, so the running total only ever moves upwards as balls are thrown and never has to be retracted. Because the card is recomputed on every read, there is no moment where a cached total and the rolls disagree.
Edge cases handled: more pins than are standing, and negative pins; a re-rack after a tenth-frame strike or spare, so 10, 3, 4 is legal but 3, 8 is not; a third ball only when it is earned; a roll after the game is over, and a roll by the wrong player; a party of zero or seven; more pairs of shoes than players; an unknown booking or lane id; releasing a lane that is already free; taking a lane out of service while a game is on it; and a tie, which the base reports as a draw rather than picking a winner arbitrarily.
Common mistake
Scoring the card incrementally — adding the frame's pins to a running total as each ball lands, then trying to patch earlier frames when a bonus arrives. It works until a strike is followed by a strike, at which point the bonus for frame 8 depends on a ball in frame 10, and the patching logic grows a special case per combination. Recompute from the roll list: 21 numbers, no state, no invalidation. If the interviewer pushes on cost, that is when you say the card is bounded and the calculator is stateless, so it is also thread-safe.
Extensibility and follow-ups¶
- Leagues and handicaps: a
HandicapScoringthat wrapsStandardScoringand adds a per-player allowance to the final total. It is a decorator over the existing Strategy, and theScoreboardneeds no change because it readsStanding. - No-tap or low-ball rules: alternative
ScoreCalculatorimplementations. The frames and rolls are unchanged; only the interpretation moves. - Several games per booking: the
Bookingalready recordsgames. Keep a list ofBowlingGameobjects per booking and release the lane when the last one finishes. - Reservations for a future time slot:
Lanegains a schedule instead of a single status, andreservebecomes an interval overlap check. That is where the single alley lock starts to hurt, and where a per-lane lock ordered by id is the right next step. - Pin-setter integration:
roll(player, pins)is already the port. A hardware adapter calls it, and a mis-read is exactly the invalid pin count the model already rejects. - Multiple branches: this becomes a system design question — a lane availability service, bookings with idempotency keys, and eventual consistency between the branch screens and the central view.
Interview tip
When you get to scoring, write the tenth frame first and the first nine second. Everyone can score an open frame; the tenth is where the rules bite, and starting there forces you to decide early whether it is a subclass or a flag. Announce the decision — "one class, one flag, because the game must not care" — and you have answered the design question before you write the arithmetic.
Tests¶
tests/test_bowling_alley.py has 18 cases. The parametrized card table is the one to show first: five known games, one line each, and any scoring bug fails at least one of them.
@pytest.mark.parametrize(
("tenth", "rolls", "total"),
[
([10, 10, 10], 3, 30), # three strikes in the tenth
([10, 3, 4], 3, 17), # a strike re-racks, then an ordinary pair
([7, 3, 5], 3, 15), # a spare re-racks, then one bonus ball
([4, 5], 2, 9), # an open tenth stops after two
],
)
def test_the_tenth_frame_takes_a_third_ball_only_when_it_is_earned(
tenth: list[int], rolls: int, total: int
) -> None:
game = solo([0] * 18 + tenth)
frame = game.card("P")[9]
assert len(frame.rolls) == rolls and frame.is_complete()
assert game.total("P") == total and game.status is GameStatus.WON
The provisional-score tests are the ones that catch an incremental scorer. They assert the total before the bonus lands as well as after:
def test_a_mark_stays_provisional_until_its_bonus_balls_are_thrown() -> None:
game = BowlingGame(["P"])
game.roll("P", 7)
spare = game.roll("P", 3)
assert (spare.frame_type, spare.status, spare.running_total) == (
FrameType.SPARE,
FrameStatus.AWAITING_BONUS,
10, # the provisional total counts the bonus as zero
)
game.roll("P", 4) # the next ball resolves the spare
card = game.scorecard("P")
assert (card[0].status, card[0].bonus, card[0].running_total) == (FrameStatus.SCORED, 4, 14)
assert (card[1].status, card[1].running_total) == (FrameStatus.IN_PROGRESS, 18)
def test_a_strike_needs_two_bonus_balls_before_it_settles() -> None:
game = BowlingGame(["P"])
game.roll("P", 10)
game.roll("P", 3)
assert game.scorecard("P")[0].status is FrameStatus.AWAITING_BONUS
game.roll("P", 4)
card = game.scorecard("P")
assert (card[0].status, card[0].running_total) == (FrameStatus.SCORED, 17)
assert card[1].running_total == 24
def test_concurrent_reservations_hand_out_each_lane_exactly_once() -> None:
alley = alley_with(4)
def book(i: int) -> str | None:
try:
return alley.reserve([f"p{i}"]).lane_id
except LaneUnavailableError: # someone else took the last lane
return None
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(book, range(30)))
lanes = [lane for lane in results if lane is not None]
assert len(lanes) == 4 and len(set(lanes)) == 4 # every lane booked exactly once
assert results.count(None) == 26 and alley.free_lanes() == 0
The rest cover: the gutter game, the all-fives game, the perfect game, the textbook 133 card and a nine-and-miss game; invalid pin counts including the tenth-frame re-rack; the ball passing only when a frame closes, and an out-of-turn roll; every guarded lane transition; pricing with and without a discount; the scoreboard and the log observing the same game; and the alley refusing bad party sizes, unknown ids and a double release. Run them with uv run pytest code/lld/bowling_alley -q.
45-minute pacing¶
| Minutes | What to do | What to say or write |
|---|---|---|
| 0–5 | Clarify | Tenth frame rules? Turn rotation per ball or per frame? Live scores while bonuses are pending? Out of scope: hardware, food, leagues. |
| 5–10 | Split the problem | Two halves on the board: the house (lanes, bookings, pricing) and the game (frames, rolls, scoring). Say which half you will do first and why. |
| 10–20 | The frame | remaining_pins, is_complete, frame_type. Do the tenth frame first. State the one-class-one-flag decision out loud. |
| 20–28 | Scoring | StandardScoring.score over the flattened roll list, with AWAITING_BONUS for unresolved marks. Show the provisional total on the board. |
| 28–34 | The game | Reuse BoardGame, override advance_turn, and point out that this is the payoff for having built the template in the first place. |
| 34–40 | The house and concurrency | reserve inside one lock, the lane state machine, and why the alley lock and the game lock never nest. |
| 40–45 | Extensions | Handicaps as a decorator, time-slot reservations and per-lane locks, pin-setter as an adapter, multiple branches as the HLD hand-off. |
Related¶
- Design tic-tac-toe (an extensible board game) — where
BoardGame, the turn cursor and the observers come from - Template Method — the skeleton, and the
advance_turnhook this problem needs - State — the frame and lane lifecycles, and when to promote an enum to classes
- Object Pool — lanes as the scarce reusable resource
- Strategy — scoring and pricing policies
- Design a parking lot — the other allocation problem, with a finer lock granularity