The 100 problems, solved / Games / 072 · Design Monopoly

Design Monopoly

Board squares with different behaviours, money, properties and turns.

Concepts this design leans on

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 with on_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 with apply(game, player); a CardDeck cycles through them.
  • Player: name, cash, position, jail state and a Strategy.
  • Strategy: the player's decisions, such as whether to buy.
  • Game: the turn order, dice, money movement and bankruptcy.
GameBoardPlayers and cards2..8401..*ownerGameboard: Boardplayers: deque[Player]dice: Diceplay_turn()move(player, steps)send_to_jail(player)pay(player, to, amount)Boardsquares: list[Square]jail_index: intat(position) -> SquareSquarename: stron_land(game, player)Propertypricerentowner: Player | NoneTaxamountCardSquaredeck: CardDeckPlayernamecashpositionjail_turnsstrategy: StrategyCardapply(game, player)Strategywants_to_buy(player, prop) -> bool

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

  1. play_turn takes the player at the front of the deque.
  2. If jailed, it tries a double, pays the fine on the third failed turn, or ends the turn.
  3. Otherwise it rolls; a third double in a row sends the player to jail.
  4. move advances around the ring, pays the salary on passing Go, and calls on_land on the destination.
  5. on_land may buy, charge rent, draw a card, or jail the player.
  6. 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." Property gains 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 Railway subclass overrides the rent calculation; Game never knows.
  • "Trading and auctions." Both move money and ownership between players, so they route through pay, and the auction asks each player's Strategy for a bid.
  • "A card that says go to jail." One more Card subclass calling send_to_jail; note that AdvanceCard triggers the next square's effect through move, which is the correct rule and also why on_land must 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 Square enum 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