Design Monopoly
Board squares with different behaviours, money, properties and turns.
- Polymorphism — One call, many behaviours: the code that removes the if-else ladder.
- State machines and the State pattern — Behaviour that depends on a mode, without a wall of conditionals.
- Command — Turning an action into an object you can queue, log and undo.
- From requirements to classes — Nouns, verbs and the use-case walk that finds the classes.
- Liskov substitution — A subclass must be usable wherever its parent is.
01The design, in the six-step framework
1. Requirements and use cases
The Google report on this one is a single line: build an object-oriented design for Monopoly. It is a good test of polymorphism, because a board of forty squares that all behave differently is the classic place where a giant switch grows. Scope it hard before drawing anything.
- Players take turns rolling two dice and moving around a ring of squares, collecting money for passing Go.
- Landing on an unowned property, a player may buy it; landing on an owned one, they pay rent to the owner.
- Tax squares charge money; Chance squares draw a card that moves or pays; Go To Jail sends the player to jail.
- Rolling doubles gives another turn; three doubles in a row goes to jail. A jailed player pays a fine or waits up to three turns.
- A player who cannot pay is bankrupt and leaves; the last player standing wins.
Out of scope for the first pass: houses and hotels, trading, auctions, mortgages. They are named, and the design shows where each would go. Constraints: a new square type must not touch the turn loop; player decisions are pluggable so a bot or test can play; dice and card order are injected so a game replays exactly.
2. Core entities and relationships
Square: an abstract class withon_land(game, player); each kind supplies its effect.Property,Tax,GoToJail,CardSquare,Plain: the concrete squares.Board: the ring of squares; wraps positions and locates the jail.Card: a command object withapply(game, player); aCardDeckcycles through them.Player: name, cash, position, jail state and aStrategy.Strategy: the player's decisions, such as whether to buy.Game: the turn order, dice, money movement and bankruptcy.
The board composes its squares. A property only points at its owner, since ownership changes during a game and a bankrupt player's properties return to the bank.
3. Interfaces and key methods
import random
from abc import ABC, abstractmethod
from collections import deque
from dataclasses import dataclass, field
from typing import Optional, Protocol, Tuple
class Dice(Protocol):
def roll(self) -> Tuple[int, int]:
"""Two dice."""
class Strategy(Protocol):
def wants_to_buy(self, player: "Player", prop: "Property") -> bool:
"""Decide on an unowned property the player can afford."""
class Square(ABC):
def __init__(self, name: str) -> None:
self.name = name
@abstractmethod
def on_land(self, game: "Game", player: "Player") -> None:
"""The effect of ending a move here. May move the player or charge money."""
class Card(ABC):
@abstractmethod
def apply(self, game: "Game", player: "Player") -> None:
"""Carry out the card's instruction."""
class Game:
def play_turn(self) -> None:
"""One full turn for the current player, including doubles and jail."""
def move(self, player: "Player", steps: int) -> None:
"""Advance around the ring, pay the Go salary, trigger the landing square."""
def pay(self, player: "Player", to: Optional["Player"], amount: int) -> None:
"""Move money; `to` None is the bank; a shortfall bankrupts the player."""
Invariants: all money moves through Game.pay, so the bankrupt rule lives in one place; a position is always a valid index; a property has at most one owner; on_land never raises on a legal game state, it just applies its effect.
4. Design patterns and the choices behind them
Squares are polymorphic
The force: forty squares, a handful of behaviours. An if square.type == ... chain in the turn loop would grow with every new square and be repeated by anything else that cares. Instead the loop says square.on_land(game, player) and polymorphism picks the effect. A new square is a new class, and the loop stays closed.
The contract matters for Liskov substitution. Every Square must accept any player in any state and leave the game consistent. Tax must not raise when a player is short of money; it calls pay, and bankruptcy is handled there. A subclass that threw on a broke player would force the loop to know which squares can fail, which is exactly what the hierarchy exists to avoid.
Cards are commands
The force: "advance to Go", "pay 50", "go to jail" are instructions held in a deck and run later. Each is a small object with apply, the command pattern, and a deck is a queue of commands. New cards are one class or one parameter, and logging or replaying a game comes for free. The deck rotates, drawing from the front and returning the card to the back, which matches the real game and needs no reshuffle logic.
A jail state rather than scattered checks
A player is either free or in jail with a count. Only play_turn consults it, branching once at the top to the jail path, a light state treatment. A full State class per player status would be justified only if more statuses appear. Classes come from the use-case walk; Bank is deliberately not one of them, because it holds no state in this scope and is represented by to=None.
5. Core flow and code
play_turntakes the player at the front of the deque.- If jailed, it tries a double, pays the fine on the third failed turn, or ends the turn.
- Otherwise it rolls; a third double in a row sends the player to jail.
moveadvances around the ring, pays the salary on passing Go, and callson_landon the destination.on_landmay buy, charge rent, draw a card, or jail the player.- A double repeats the loop if the player is still in the game and not jailed; otherwise the turn passes.
class Plain(Square):
def on_land(self, game: "Game", player: "Player") -> None:
pass # Go, Jail (just visiting), Free Parking
class Property(Square):
def __init__(self, name: str, price: int, rent: int) -> None:
super().__init__(name)
self.price, self.rent = price, rent
self.owner: Optional["Player"] = None
def on_land(self, game: "Game", player: "Player") -> None:
if self.owner is None:
if player.cash >= self.price and player.strategy.wants_to_buy(player, self):
game.pay(player, None, self.price)
self.owner = player
elif self.owner is not player:
game.pay(player, self.owner, self.rent)
class Tax(Square):
def __init__(self, name: str, amount: int) -> None:
super().__init__(name)
self.amount = amount
def on_land(self, game: "Game", player: "Player") -> None:
game.pay(player, None, self.amount)
class GoToJail(Square):
def on_land(self, game: "Game", player: "Player") -> None:
game.send_to_jail(player)
class PayCard(Card):
def __init__(self, amount: int) -> None:
self.amount = amount
def apply(self, game: "Game", player: "Player") -> None:
game.pay(player, None, self.amount)
class CollectCard(Card):
def __init__(self, amount: int) -> None:
self.amount = amount
def apply(self, game: "Game", player: "Player") -> None:
player.cash += self.amount
class AdvanceCard(Card):
def __init__(self, steps: int) -> None:
self.steps = steps
def apply(self, game: "Game", player: "Player") -> None:
game.move(player, self.steps)
class CardDeck:
def __init__(self, cards: list) -> None:
self.cards = deque(cards)
def draw(self) -> Card:
card = self.cards.popleft()
self.cards.append(card)
return card
class CardSquare(Square):
def __init__(self, name: str, deck: CardDeck) -> None:
super().__init__(name)
self.deck = deck
def on_land(self, game: "Game", player: "Player") -> None:
self.deck.draw().apply(game, player)
class AlwaysBuy:
def wants_to_buy(self, player: "Player", prop: Property) -> bool:
return True
@dataclass
class Player:
name: str
strategy: Strategy
cash: int = 1500
position: int = 0
jail_turns: int = 0 # above zero means in jail
bankrupt: bool = False
class Board:
def __init__(self, squares: list, jail_index: int) -> None:
self.squares, self.jail_index = squares, jail_index
def at(self, position: int) -> Square:
return self.squares[position]
class Game:
SALARY, JAIL_FINE = 200, 50
def __init__(self, board: Board, players: list, dice: Dice) -> None:
self.board, self.dice = board, dice
self.players = deque(players)
def pay(self, player: Player, to: Optional[Player], amount: int) -> None:
paid = min(amount, player.cash)
player.cash -= paid
if to is not None:
to.cash += paid
if paid < amount:
self._bankrupt(player)
def _bankrupt(self, player: Player) -> None:
player.bankrupt = True
for square in self.board.squares:
if isinstance(square, Property) and square.owner is player:
square.owner = None
self.players.remove(player)
def send_to_jail(self, player: Player) -> None:
player.position = self.board.jail_index
player.jail_turns = 1
def move(self, player: Player, steps: int) -> None:
target = player.position + steps
if target >= len(self.board.squares):
player.cash += self.SALARY
player.position = target % len(self.board.squares)
self.board.at(player.position).on_land(self, player)
def _jail_turn(self, player: Player) -> None:
"""Roll for doubles; on the third miss, pay the fine and move anyway."""
a, b = self.dice.roll()
if a != b and player.jail_turns < 3:
player.jail_turns += 1
return
if a != b:
self.pay(player, None, self.JAIL_FINE)
player.jail_turns = 0
if not player.bankrupt:
self.move(player, a + b)
def play_turn(self) -> None:
player = self.players[0]
if player.jail_turns:
self._jail_turn(player)
else:
for doubles in range(3):
a, b = self.dice.roll()
if a == b and doubles == 2:
self.send_to_jail(player)
break
self.move(player, a + b)
if a != b or player.bankrupt or player.jail_turns:
break
if self.players and self.players[0] is player:
self.players.rotate(-1)
def winner(self) -> Optional[Player]:
return self.players[0] if len(self.players) == 1 else None
if __name__ == "__main__":
class Script:
def __init__(self, rolls: list) -> None:
self.rolls = deque(rolls)
def roll(self) -> Tuple[int, int]:
return self.rolls.popleft()
street = Property("Street", 100, 30)
board = Board([Plain("Go"), Plain("A"), street, Tax("Tax", 100), GoToJail("Cop"), Plain("Jail")], 5)
ann, bo = Player("ann", AlwaysBuy()), Player("bo", AlwaysBuy())
game = Game(board, [ann, bo], Script([(1, 1), (1, 2), (2, 1), (1, 1), (1, 2)]))
game.play_turn() # ann: double to Street (buys), then 3 to Jail, visiting
assert street.owner is ann and ann.cash == 1400 and ann.position == 5 and not ann.jail_turns
game.play_turn() # bo: 3 lands on Tax
assert bo.cash == 1400 and bo.position == 3
game.play_turn() # ann: double passes Go to A, then 3 lands on Cop
assert ann.cash == 1600 and ann.position == 5 and ann.jail_turns == 1
6. Extensions, concurrency and what interviewers probe
- "Add houses and colour sets."
Propertygains a house count and a colour group; rent becomes a method. Only that class changes, because rent is asked of it, not computed outside. - "Railways: rent depends on how many you own." A
Railwaysubclass overrides the rent calculation;Gamenever knows. - "Trading and auctions." Both move money and ownership between players, so they route through
pay, and the auction asks each player'sStrategyfor a bid. - "A card that says go to jail." One more
Cardsubclass callingsend_to_jail; note thatAdvanceCardtriggers the next square's effect throughmove, which is the correct rule and also whyon_landmust be re-entrant. - Concurrency: one game is strictly sequential; a server runs each game on its own task and queues player decisions, so there are no locks in the model.
- Common mistake: a
Squareenum with all logic in the turn loop, and modifying the players deque during a turn without handling the bankrupt player being removed, which then rotates the wrong person.
02Where this question was reported
- Glassdoor · Glassdoor Google SWE question; no follow-ups recorded