The 100 problems, solved / Booking, allocation and simulators / 049 · Design an elevator controller

Design an elevator controller

N floors, one car, hall calls and car calls, SCAN scheduling and a state machine for doors.

Concepts this design leans on

01The design, in the six-step framework

1. Requirements and use cases

A person on a floor presses up or down: a hall call. A person inside presses a floor number: a car call. The car serves calls in a sensible order, stops at each requested floor, opens and closes its doors, and never moves with the doors open. A hall call for up is served when the car is travelling up, so the passenger does not board a car going the wrong way. A display can learn when the car arrives at a floor.

Out of scope: several cars, which is the multi-elevator dispatcher; weight limits; emergency modes. The sources agree that keeping hall calls and car calls apart is the expected insight.

Constraints: behaviour is driven by a tick so a simulation and a test can step through time deterministically, with no threads or sleeping; the scheduling rule can be replaced without touching the car.

2. Core entities and relationships

  • Elevator: the car. It holds its position, its direction, its door state, and three sets of pending stops.
  • Direction and DoorState: small enums.
  • Scheduler: decides the next stop from the state it is given. ScanScheduler is the classic sweep.
  • listener: a callback fired on arrival, for displays and tests.
StatesCarPolicy0..*DirectionUP, DOWN, IDLEDoorStateCLOSED, OPENElevatorfloor, direction, door_up, _down: hall calls_car: car callshall_call(floor, direction)car_call(floor)tick()on_arrival(listener)Schedulernext_stop(...) -> int | NoneScanScheduler_up(...) / _down(...)listener: (floor) -> None

Starting from the use cases, notice what is missing: there is no Floor class and no Button class. Neither holds state or enforces a rule the car does not. Sets of integers are enough, and the diagram stays small. The scheduler is used and receives everything it needs as arguments, so it stays stateless.

3. Interfaces and key methods

class Direction(Enum):
    UP = 1; DOWN = -1; IDLE = 0

class DoorState(Enum):
    CLOSED = auto(); OPEN = auto()

class Scheduler(Protocol):
    def next_stop(self, floor: int, direction: Direction,
                  up: set[int], down: set[int], car: set[int]) -> int | None: ...

class Elevator:
    def hall_call(self, floor: int, direction: Direction) -> None:
        """Someone on a floor pressed up or down."""
    def car_call(self, floor: int) -> None:
        """Someone inside pressed a floor button."""
    def tick(self) -> None:
        """Advance one time step: move one floor, or open or close the doors."""
    def on_arrival(self, listener: Callable[[int], None]) -> None: ...

Invariants: the car never changes floor while the door is OPEN; a floor is cleared from a call set only when the doors open there; a hall call for direction d is cleared only when the car is leaving in direction d, or has nothing left beyond it; pressing a button twice is harmless, because calls are sets.

4. Design patterns and the choices behind them

Separate sets for up, down and car calls

One sorted list of target floors loses information. A person at floor 5 going down should not stop a car rising to floor 8. Keeping _up, _down and _car apart lets the car stop only when the direction matches. This could be a pair of heaps for speed; with a few dozen floors, sets are simpler and clearing a floor is direct.

Scheduling as a strategy

SCAN, shortest-seek and first-come rules differ only in choosing the next stop. A strategy receives position and pending calls and returns a floor, so swapping it is one constructor argument. Passing the sets rather than the Elevator keeps the scheduler testable alone. The turning rule matters: travelling up with only a down call at floor 9 above, SCAN goes to 9, then reverses.

A tick-driven state machine

Each tick does exactly one thing: close doors, open doors, or move one floor. That small state machine makes "never move with doors open" true by construction, since the door check is the first line of tick. A thread per car with sleeps was rejected because no test could assert a timeline. A real controller calls tick from a timer.

Observer for arrival

A floor display or the dispatcher can subscribe through on_arrival with no change to the car, which is the plain observer case.

5. Core flow and code

  1. The car is idle at floor 0. hall_call(5, DOWN) and hall_call(3, UP) arrive.
  2. tick: doors are closed and calls exist. The scheduler looks up: up calls at or above 0 include 3, so the target is 3. Direction becomes UP and the car moves one floor per tick.
  3. At floor 3 the direction is UP and 3 is in _up, so the doors open, the call clears and the listeners fire. The next tick closes the doors.
  4. The passenger presses 7: car_call(7). The car rises past 5, because 5 is a down call and the car is going up.
  5. At 7 the doors open. No calls remain above, so the car reverses for the down call at 5.
  6. At 5 the direction is DOWN, the doors open and the call clears. With nothing pending, the next tick sets IDLE.
from __future__ import annotations
from enum import Enum, auto
from typing import Callable, Protocol

class Direction(Enum):
    UP = 1; DOWN = -1; IDLE = 0

class DoorState(Enum):
    CLOSED = auto(); OPEN = auto()

class Scheduler(Protocol):
    def next_stop(self, floor: int, direction: Direction,
                  up: set[int], down: set[int], car: set[int]) -> int | None: ...

class ScanScheduler:
    """Continue in the current direction while anything lies ahead, then reverse."""

    def next_stop(self, floor: int, direction: Direction,
                  up: set[int], down: set[int], car: set[int]) -> int | None:
        order = (self._down, self._up) if direction is Direction.DOWN else (self._up, self._down)
        for sweep in order:
            target = sweep(floor, up, down, car)
            if target is not None:            # floor 0 is a valid target, so test for None
                return target
        return None

    def _up(self, floor, up, down, car):
        ahead = [f for f in up | car if f >= floor]
        if ahead:
            return min(ahead)
        turn = [f for f in down if f >= floor]        # go to the top down-caller, then reverse
        return max(turn) if turn else None

    def _down(self, floor, up, down, car):
        ahead = [f for f in down | car if f <= floor]
        if ahead:
            return max(ahead)
        turn = [f for f in up if f <= floor]
        return min(turn) if turn else None

class Elevator:
    def __init__(self, floors: int, scheduler: Scheduler, start: int = 0) -> None:
        self.floors = floors
        self.floor = start
        self.direction = Direction.IDLE
        self.door = DoorState.CLOSED
        self._scheduler = scheduler
        self._up: set[int] = set()
        self._down: set[int] = set()
        self._car: set[int] = set()
        self._listeners: list[Callable[[int], None]] = []

    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) -> None:
        self._check(floor)
        if direction is Direction.IDLE:
            raise ValueError("a hall call has a direction")
        (self._up if direction is Direction.UP else self._down).add(floor)

    def car_call(self, floor: int) -> None:
        self._check(floor)
        self._car.add(floor)

    def on_arrival(self, listener: Callable[[int], None]) -> None:
        self._listeners.append(listener)

    def _stops_here(self) -> bool:
        if self.floor in self._car:
            return True
        if self.direction is Direction.UP and self.floor in self._up:
            return True
        if self.direction is Direction.DOWN and self.floor in self._down:
            return True
        # at a turning point or idle, serve whichever call is waiting here
        target = self._scheduler.next_stop(self.floor, self.direction, self._up, self._down, self._car)
        return target == self.floor

    def _clear_calls(self) -> None:
        self._car.discard(self.floor)
        if self.direction is Direction.DOWN:
            self._down.discard(self.floor)
            if not self._calls_below():
                self._up.discard(self.floor)
        else:
            self._up.discard(self.floor)
            if not self._calls_above():
                self._down.discard(self.floor)

    def _calls_above(self) -> bool:
        return any(f > self.floor for f in self._up | self._down | self._car)

    def _calls_below(self) -> bool:
        return any(f < self.floor for f in self._up | self._down | self._car)

    def tick(self) -> None:
        if self.door is DoorState.OPEN:               # a closing tick; never move with doors open
            self.door = DoorState.CLOSED
            return
        if not (self._up or self._down or self._car):
            self.direction = Direction.IDLE
            return
        if self._stops_here():
            self.door = DoorState.OPEN
            self._clear_calls()
            for listener in self._listeners:
                listener(self.floor)
            return
        target = self._scheduler.next_stop(self.floor, self.direction, self._up, self._down, self._car)
        self.direction = Direction.UP if target > self.floor else Direction.DOWN
        self.floor += self.direction.value

6. Extensions, concurrency and what interviewers probe

  • Load limit: a reported variant. Add a load field; when full, skip hall calls without clearing them. Only _stops_here changes.
  • Door hold: passengers need more than one tick. Add a countdown; a door-open button extends it, and the invariant stays because the door check comes first.
  • Thread safety: buttons arrive from other threads. Queue the calls and apply them at the start of each tick, or take one lock around the public methods.
  • Starvation: SCAN guarantees every call is reached within two sweeps, which is why it is preferred over nearest-first, which can leave an end floor waiting indefinitely.
  • Test first: run the six-step scenario with a listener, assert the arrival order 3, 7, 5, and assert after every tick that the floor did not change while the door was open.
  • Floor 0: next_stop compares with None rather than using truthiness, because the ground floor is a valid answer; that slip is easy to make in Python and hard to spot in a demo that starts at floor 0.
  • Common mistake: one queue of requests served in arrival order, which sends the car back and forth, and clearing a down call as the car passes upward.

02Where this question was reported

  • Hello Interview · Hello Interview breakdown (Medium, free); ElevatorController/Elevator/Request; junior-mid-senior expectations
  • Educative · Grokking LLD chapter 8
  • DesignGurus · DesignGurus Grokking OOD case study
  • ByteByteGo · ByteByteGo OOD chapter 8
  • LLD Mastery · Google-tagged (Easy, free); request dispatching, state management; also single-lift SCAN and request-feasibility variants
  • LLD Mastery · Google-tagged with Amazon/Microsoft/Uber; state machine, dispatch strategy, request queues
  • PracHub · PracHub Google-tagged (Hard): N floors, M elevators, hall vs car calls; Controller/Scheduler classes
  • PracHub · PracHub LLD guide: elevator control system attributed to Google, plus a senior variant (peak traffic, fairness, fault tolerance)
  • interviewkickstart.com · Interview Kickstart Google list: 'How do you design an elevator system?'
  • Exponent / Aced · Exponent/Aced question 'Design elevator (LLD)' (tagged Morgan Stanley, L4); related: /questions/235/design-elevator
  • Codemia · Codemia Medium
  • GitHub · awesome-low-level-design, Medium