~learn.

LLD 0411 min+60 XP

Design an Elevator System.

Design a multi-car elevator controller, then simulate it

6 code

Lesson · 11 min

The problem where the hard part isn't the classes. It's deciding which car answers a button, and in what order one car serves its stops.

The elevator is a favourite low-level design prompt, in big-tech loops and Indian machine-coding rounds alike, because it has real behaviour, not just data. Candidates who model it as "a list of floors to visit" end up with a car that serves floors in the order they were pressed, zig-zagging from 9 to 1 to 8. The interviewer wants a car that sweeps, a controller that picks sensibly, a design where the dispatch rule is a strategy, and a simulation that shows it working.

This lesson keeps it tractable in 90 minutes: one car's schedule (the LOOK algorithm), a cost-based dispatcher, a controller that validates buttons and drives a tick-based simulation, and display panels that subscribe to events.

ExplainerThe whole design: Elevator system

A 10-floor building with two cars: hall calls carry UP or DOWN, car calls just a floor. The controller's dispatch strategy picks the car with the lowest cost to reach the caller, and each car serves its stops with LOOK, keeping on while stops lie ahead and then turning (8, 9, then 1 is 12 floors, not 19). A scripted morning shows calls landing on cars already heading that way; one lock guards buttons and ticks, displays are told after it, and capacity, maintenance, zoning and fire alarms are new strategies or states.

How the round runs#

Interviewer

"Design the elevator system for a 10-floor office building with two cars. People press UP or DOWN on a floor; inside, they press a floor number. Decide which car goes where. Simulate it in code: I want to see the cars move. 90 minutes."

A good 90 minutes
  • 0–10 min —
    clarify: hall calls vs car calls, how time moves, what the controller decides, what's out of scope
  • 10–25 min —
    the classes and the two decisions: which car (dispatch), and in which order (per-car schedule)
  • 25–55 min —
    the Elevator and its LOOK schedule; get one car right before adding a second
  • 55–70 min —
    the dispatch strategy and the controller
  • 70–80 min —
    a scripted morning: calls, ticks, printed events
  • 80–90 min —
    follow-ups: capacity, maintenance mode, peak-hour zoning, an emergency stop

Requirements and clarifying questions#

You ask They answer What it changes
What can a person press? UP/DOWN on a floor (hall call); a floor number inside (car call). Two request kinds; a hall call carries a direction
Who picks the car for a hall call? A central controller. Car calls go to that car. An ElevatorController and a DispatchStrategy
How does time move? Your choice; it's a simulation. A tick(): each car moves one floor or opens/closes its doors
Should a car going up stop for someone who wants to go down? No, not on the way up; on the way back. Stops remember the direction wanted
Pressing a lit button again? No effect. Deduplicate hall calls across all cars
Weight limits, emergency, maintenance? Mention them as extensions. Nothing now
Your turn · checkpoint 1not answered

One car, stops at 9, 1 and 8, car at 5 going up. In what order?

A car is at floor 5 moving up. It has car calls for floors 9, 1 and 8, pressed in that order. Serving them in press order (9, 1, 8) travels 4 + 8 + 7 = 19 floors. What order should it use, and what is that rule called?

Think it through, or jot it down. Then compare.

One strong answer

8, 9, then 1: keep going in the current direction while there are stops ahead, serving them in floor order, then reverse. That's 3 + 1 + 8 = 12 floors, and no rider in the car is carried past their floor and back. Going all the way to the top floor before turning is the SCAN (or "elevator") algorithm; turning at the last requested stop, as here, is LOOK. Real controllers use LOOK.

The classes#

DiagramElevator system: the classes
%% title: Elevator system: the classes
classDiagram
  class ElevatorController {
    +floors int
    +hall_call(floor, direction) str
    +car_call(car_id, floor)
    +tick()
  }
  class Elevator {
    +eid str
    +floor int
    +direction Direction
    +doors_open bool
    -stops dict
    +add_stop(floor, wanted)
    +step() str
  }
  class HallCall {
    +floor int
    +direction Direction
  }
  class Direction {
    <<enumeration>>
    UP
    DOWN
    IDLE
  }
  class DispatchStrategy {
    <<interface>>
    +choose(cars, call) Elevator
  }
  class LookCostDispatch {
    +cost(car, call) int
  }
  class Listener {
    <<interface>>
    +on_event(car_id, floor, event)
  }
  ElevatorController "1" *-- "many" Elevator
  ElevatorController --> DispatchStrategy
  ElevatorController --> Listener
  DispatchStrategy <|.. LookCostDispatch
  DispatchStrategy ..> HallCall
  Elevator --> Direction

Two decisions, two homes. Which car answers a hall call is the controller's job, delegated to a DispatchStrategy. In what order a car serves its stops is the car's own job: Elevator.step() runs LOOK. Keeping these apart is the core of the design: you can test one car's schedule with no controller at all, and swap the dispatcher without touching a car.

Each car keeps stops: floor → set of directions wanted. A car call stores IDLE ("stop whichever way you're going"); a hall call stores UP or DOWN. That is what lets a car going up pass floor 5 where someone pressed DOWN, and stop there on the way back.

The interfaces#

pythonDirection · 23 lines
# elevator.py, part 1: requests and directions
from __future__ import annotations

import threading
from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum


class Direction(Enum):
    UP = 1
    DOWN = -1
    IDLE = 0  # also marks a car call: "stop here, whichever way you're going"


@dataclass(frozen=True)
class HallCall:  # a person on a floor pressed UP or DOWN
    floor: int
    direction: Direction


Listener = Callable[[str, int, str], None]  # (elevator id, floor, event)

One car: the LOOK schedule#

step() is one tick. In order: close the doors if they're open; stop here if someone on this floor wants this direction (or it's a car call, or the run ends here); otherwise keep moving while there are stops ahead, or turn round if there are only stops behind, or go idle. A hall call for the opposite direction is served only at the end of a run, and the car turns to face the caller's way before opening.

pythonElevator · 48 lines
# part 2: one car and its LOOK schedule (keep going while there are stops ahead, then turn)
class Elevator:
    def __init__(self, eid: str, floor: int = 0) -> None:
        self.eid, self.floor = eid, floor
        self.direction = Direction.IDLE
        self.doors_open = False
        self.stops: dict[int, set[Direction]] = {}  # floor -> who wants it (UP, DOWN or IDLE for a car call)

    def add_stop(self, floor: int, wanted: Direction) -> None:
        self.stops.setdefault(floor, set()).add(wanted)
        if self.direction is Direction.IDLE and floor != self.floor:  # an idle car now has somewhere to go
            self.direction = Direction.UP if floor > self.floor else Direction.DOWN

    def has_call(self, call: HallCall) -> bool:
        return call.direction in self.stops.get(call.floor, set())

    def ahead(self, d: Direction) -> bool:
        return any((f - self.floor) * d.value > 0 for f in self.stops)

    def _stop_here(self) -> bool:
        wants = self.stops.get(self.floor)
        if not wants:
            return False
        if self.direction is Direction.IDLE or Direction.IDLE in wants or self.direction in wants:
            return True
        return not self.ahead(self.direction)  # opposite-way call: take it only at the end of the run

    def step(self) -> str:
        if self.doors_open:
            self.doors_open = False
            return "doors close"
        if self._stop_here():
            wants = self.stops[self.floor]
            if not self.ahead(self.direction) and self.direction not in wants:
                self.direction = next(iter(wants - {Direction.IDLE}), self.direction)  # turn for the caller
            wants -= {self.direction, Direction.IDLE}
            if not wants:
                del self.stops[self.floor]
            self.doors_open = True
            if not self.stops:
                self.direction = Direction.IDLE
            return "doors open"
        if self.direction is Direction.IDLE or not self.ahead(self.direction):
            self.direction = next((d for d in (Direction.UP, Direction.DOWN) if self.ahead(d)), Direction.IDLE)
            if self.direction is Direction.IDLE:
                return "idle"
        self.floor += self.direction.value
        return f"moving {self.direction.name.lower()}"

Which car: dispatch as a strategy#

The cost is "how many floors before this car can pick the caller up":

  • an idle car: the plain distance;
  • a car moving the caller's way, with the caller ahead: the distance, because the caller is on its route;
  • otherwise the car must finish its run first: the distance to where the run ends (its farthest stop in the current direction), plus the distance from there back to the caller.

Ties go to the lower car id, so the result is deterministic.

pythonDispatchStrategy · 23 lines
# part 3: which car answers a hall call is a strategy
class DispatchStrategy(ABC):
    @abstractmethod
    def choose(self, cars: list[Elevator], call: HallCall) -> Elevator: ...


class LookCostDispatch(DispatchStrategy):
    """Cost = floors the car must travel before it can pick the caller up."""

    @staticmethod
    def cost(car: Elevator, call: HallCall) -> int:
        if car.direction is Direction.IDLE:
            return abs(car.floor - call.floor)
        same_way = call.direction is car.direction
        in_front = (call.floor - car.floor) * car.direction.value >= 0
        if same_way and in_front:
            return abs(call.floor - car.floor)
        ends = [f for f in car.stops] + [car.floor]
        turn = max(ends) if car.direction is Direction.UP else min(ends)  # where this run ends
        return abs(turn - car.floor) + abs(turn - call.floor)

    def choose(self, cars: list[Elevator], call: HallCall) -> Elevator:
        return min(cars, key=lambda c: (self.cost(c, call), c.eid))

The patterns, and why#

Pattern Where Why it earns its place
Strategy DispatchStrategy / LookCostDispatch Dispatch rules change with the building: peak-hour zoning, an express car, energy saving. Each is a new strategy.
State (as an enum) Direction and doors_open drive what step() does The car's behaviour depends on its state. With four states an enum is enough; the vending machine lesson shows the full State pattern for when the states multiply.
Observer listeners on the controller Floor displays, a dashboard and a logger subscribe to events. The car doesn't know who's watching.
Command (light) HallCall as a value object A request is data that can be queued, deduplicated, logged and replayed.

Concurrency and edge cases#

Buttons are pressed on many threads; tick() runs on one simulation thread. One lock in the controller guards every change to a car's stops and every step(), so a call can't be added halfway through a car deciding its direction. Listeners are notified after the lock is released: a slow display must not freeze the building.

Edge cases
  • A call for the floor the car is on: the next tick opens the doors; no movement
  • The same hall button pressed again: return the car already assigned; add nothing
  • Impossible buttons: DOWN on the ground floor, UP on the top floor, floor 42 in a 10-floor building: reject
  • An idle car given a stop: it must take on a direction at once, or the dispatcher will think it is free and pile more calls on it
  • Someone presses DOWN while the car passes going up: don't stop; serve them on the way back, or let another car take them
  • Nobody presses anything inside after a hall-call stop: the car closes its doors and goes idle; no phantom stops

The code#

The controller ties it together:

pythonElevatorController · 36 lines
# part 4: the controller: validates buttons, dispatches, and ticks the simulation
class ElevatorController:
    def __init__(self, floors: int, cars: list[Elevator], strategy: DispatchStrategy) -> None:
        self.floors, self.cars, self.strategy = floors, cars, strategy
        self.listeners: list[Listener] = []
        self._lock = threading.Lock()  # buttons are pressed on many threads; tick() runs on one

    def _check(self, floor: int) -> None:
        if not 0 <= floor < self.floors:
            raise ValueError(f"no floor {floor}")

    def hall_call(self, floor: int, direction: Direction) -> str:
        self._check(floor)
        if (floor == 0 and direction is Direction.DOWN) or (floor == self.floors - 1 and direction is Direction.UP):
            raise ValueError("no such button")
        call = HallCall(floor, direction)
        with self._lock:
            for car in self.cars:
                if car.has_call(call):
                    return car.eid  # pressed twice: same car, nothing new
            car = self.strategy.choose(self.cars, call)
            car.add_stop(floor, direction)
            return car.eid

    def car_call(self, eid: str, floor: int) -> None:
        self._check(floor)
        with self._lock:
            next(c for c in self.cars if c.eid == eid).add_stop(floor, Direction.IDLE)

    def tick(self) -> None:
        with self._lock:
            events = [(car, car.step()) for car in self.cars]
        for car, ev in events:  # notify outside the lock
            if ev != "idle":
                for fn in self.listeners:
                    fn(car.eid, car.floor, ev)

A short morning: two calls, a duplicate press, riders choosing floors, two more calls that land on cars already heading their way.

python22 lines
# part 5: two cars, ten floors, a short morning
if __name__ == "__main__":
    a, b = Elevator("A", 0), Elevator("B", 9)
    ctl = ElevatorController(10, [a, b], LookCostDispatch())
    ctl.listeners.append(lambda eid, fl, ev: print(f"  {eid}@{fl}: {ev}") if ev.startswith("doors open") else None)

    print("hall 3 UP ->", ctl.hall_call(3, Direction.UP))  # A: 3 floors away, B is 6 away
    print("hall 7 DOWN ->", ctl.hall_call(7, Direction.DOWN))  # B: 2 floors away
    print("hall 3 UP again ->", ctl.hall_call(3, Direction.UP))  # still A, no duplicate stop
    for _ in range(4):
        ctl.tick()
    ctl.car_call("A", 8)  # the rider who got in at 3 presses 8
    ctl.car_call("B", 0)  # the rider who got in at 7 presses ground
    print("hall 5 UP ->", ctl.hall_call(5, Direction.UP))  # A is heading up to 8 from 3: on its way
    print("hall 2 DOWN ->", ctl.hall_call(2, Direction.DOWN))  # B is heading down to 0 from 7: on its way
    for _ in range(14):
        ctl.tick()
    print("A at", a.floor, a.direction.name, "| B at", b.floor, b.direction.name)
    try:
        ctl.hall_call(9, Direction.UP)
    except ValueError as e:
        print("rejected:", e)
text13 lines
hall 3 UP -> A
hall 7 DOWN -> B
hall 3 UP again -> A
  B@7: doors open
  A@3: doors open
hall 5 UP -> A
hall 2 DOWN -> B
  A@5: doors open
  B@2: doors open
  A@8: doors open
  B@0: doors open
A at 8 IDLE | B at 0 IDLE
rejected: no such button

Car A picks up at 3, takes the call at 5 on its way to 8, and finishes there. Car B picks up at 7 and takes the DOWN call at 2 on its way to the ground floor. Neither car ever reverses with a rider who still has floors to go.

Extending it: the follow-ups#

  • "Add a capacity limit." Give the car a load (from a weight sensor) and a maximum. A full car skips hall-call stops but still serves car calls; the dispatcher gives full cars infinite cost for new hall calls.
  • "Put a car in maintenance mode." A status on the car (IN_SERVICE, MAINTENANCE); the dispatcher filters out cars not in service. Its outstanding hall calls are re-dispatched to the others.
  • "Morning peak: everyone goes up from the lobby." A ZonedDispatch strategy: car A serves floors 1–5, car B 6–9, and idle cars return to the lobby. That is a new strategy and an "idle → go to lobby" rule, nothing else.
  • "Fire alarm." A building-wide emergency state: cancel all stops, send every car to the ground floor, open doors, ignore buttons. With more states like this, move from an enum to the State pattern.
  • "Minimise waiting time, not distance." Change the cost: add a penalty per stop the car makes on the way (each costs ~10 s of door time). The interface doesn't change.
✗ Pitfall

Common mistakes#

What costs candidates the round
  • A FIFO queue of floors per car, served in press order: the car zig-zags and riders ride past their floor and back
  • Dispatch logic inside Elevator, so a car decides it's the best car with no view of the others
  • Ignoring the hall call's direction, so a car going up stops for someone who wants to go down
  • No simulation clock: time.sleep(1) between floors, so the demo takes a minute and can't be tested
  • Two cars for one call: no deduplication, so both cars arrive at floor 3
  • Building everything at once: get one car right, then add the second and the dispatcher
Quick check3 questions · +5 XP each

Elevator system

  1. Q1/3 A car at floor 5 is going up with car calls for 9, 1 and 8. Under LOOK, in what order are they served?

    Answer & explanation

    Answer: B

    LOOK keeps going while there are stops ahead, in floor order, then reverses: 8, 9, then 1, 12 floors in all. Going to the top floor before turning, when nobody asked for it, is SCAN.

  2. Q2/3 Car A is at floor 3 heading up to 8. Someone on floor 2 presses DOWN. With the cost function here, what is A's cost?

    Answer & explanation

    Answer: C

    The caller is behind A and wants the other direction, so A must finish its run first: 8 − 3 = 5 up, then 8 − 2 = 6 down. An idle car farther away can easily be cheaper.

  3. Q3/3 Why does a hall call store a direction, while a car call is stored as IDLE?

    Answer & explanation

    Answer: C

    Someone waiting to go down should not be picked up by a car on its way up. A rider inside who pressed 6 wants the car to stop at 6 whichever way it's moving.

End of LLD 04 · Design an Elevator System
Finish LLD 04Stages clear as you read them (0/1 so far). Skipping ahead doesn't count: marking complete unlocks after ~2 more min of reading.

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…