Design an Elevator System
Design an elevator LLD: hall calls, car calls, a SCAN state machine, and a controller that assigns each request to exactly one car.
Page content
Alice is on floor 8 and presses Down. Car A is idle on 9, doors closed, going nowhere. Car B is on 2, moving up, with Bob inside and a stop at 5.
If both cars claim her, two cabins arrive for one person — or neither commits and she waits. If Car B reverses at 3 to “help,” Bob never reaches 5 and the cabin yo-yos. This is an LLD interview: objects, a state machine, and one assignment — not elevator-as-a-service.
The main design question is:
How do we accept hall calls and car calls, move one or more cars, and never serve a request twice or reverse unnecessarily?
We will start with one car that re-picks the closest pending floor every tick. SCAN, a controller, and a uniqueness set appear when that car starts chasing the newest button. Same habit as Design a Parking Lot: name the invariant before the class list. Many cars means many Elevator objects and one Controller. If they later say “a tower with eight banks,” each bank is this design.
1. Clarify the problem
“Design an elevator” can mean a coding puzzle with one cabin or a bank that must not strand people. The questions that change the objects:
- How many floors? How many cars?
- Hall panels: Up and Down, except the top and bottom?
- Inside the car: one button per floor?
- Capacity or weight limit?
- Do we model door time, or teleport between floors?
- Emergency / fire service? (Usually skip.)
- Destination dispatch (pick a floor in the lobby), or classic Up/Down?
If the interviewer gives no extra constraints, I would state:
Floors 10, numbered 1–10
Cars 2 (then generalize to N)
Hall buttons UP and DOWN; floor 1 UP only, floor 10 DOWN only
Car buttons One per floor, per car
Capacity Count of passengers; full cars skip hall stops
Doors Open, dwell, close on the same tick loop
Emergency Out of scope (mention and skip)
Dispatch Classic hall call + car call, not destination panels
Time Discrete step(): one floor or one door action per tick
Express cars, destination dispatch, and fire mode sit on the same machines. They do not change “one hall call, one assignee.” Say so and move on.
2. Functional requirements
The system must:
- Accept a hall call
(floor, direction)from the landing. - Accept a car call
(elevator, floor)from inside a cabin. - Move cars, open and close doors, and stop at the right floors.
- Assign each pending hall call to at most one car.
- Keep traveling in the current direction until that sweep is empty.
- Report where each car is and what is still pending.
Not in v1: destination keypads in the lobby, express / local banks, VIP override, voice control. Those reuse HallCall, CarCall, and step(). They do not add a message bus.
3. Non-functional requirements
This is not a 50k QPS problem. Interviewers want a state machine and a scheduler, not a cluster.
| Requirement | Target |
|---|---|
| Safety | A car does not reverse with people who boarded for the old direction |
| Uniqueness | (8, DOWN) exists once; two fingers share it |
| Assignment | One car owns a hall call, or none yet — never two |
| Latency | Seconds per floor; correctness over a clever heuristic |
| Concurrency | Buttons and step() share one lock on the pending sets |
| Clock | The simulator tick, not wall-clock threads per floor |
A missed hall call is a product bug. Two cars arriving for one Down press is the same class of bug as two parking tickets for one stall.
Edge cases
- Alice and Bob both press Down on 8.
- The assigned car is full when it reaches 8.
- Someone holds the door; a bag blocks the close sensor.
- Power is cut while Car B is between 4 and 5.
- A hall call for the floor the car is already standing on.
- Up and Down both pending on the same floor.
- Every car is in
MAINTENANCE. - A car call for the floor the passenger is already on.
4. Scale
Say the numbers so nobody spends the interview on QPS.
Floors 10 – 50 in one bank
Cars 1 – 8 in one bank
Hall + car presses a few per minute at rush hour
Ticks one floor-move or door action, ~1–2 seconds of real time
A scan of eight cars and a few dozen pending floors is free. The hard part is who owns Alice and whether Car B may turn around.
If they push “a 90-story tower,” do not invent Redis. Split the building into banks (low-rise, mid-rise, high-rise). Each bank is this controller. Cars do not hop banks.
Tower
├── Bank L floors 1–20 ← this design
├── Bank M floors 1, 20–40 ← this design
└── Bank H floors 1, 40–90 ← this design
5. Entities / what we store
Six facts. The controller is the assigner; the car is the mover.
Floor number; knows which hall buttons exist
HallCall (floor, direction) UP | DOWN
CarCall (elevator_id, floor)
Door CLOSED | OPENING | OPEN | CLOSING
Elevator id, floor, state, direction, carCalls, load, door
Controller elevators[], pendingHall: Set<HallCall>, assignment map
A hall call is “someone on 8 wants to go down.” It is not Alice’s name. A car call is “this cabin must visit 5” because Bob pressed 5 inside. The pending hall set plus each car’s car-call set are the source of truth. The assignment map ((8, DOWN) → A) is derived from that set: it names who is allowed to clear the call.
Hall (8, DOWN) pending, unassigned
controller assigns A
Hall (8, DOWN) pending, owner A
A opens on 8 going DOWN
Hall (8, DOWN) gone
Alice’s destination is a CarCall on A, not a second hall call
Do not store a list of people at the landing. Two Down presses on 8 are one HallCall. Passenger identity is out of scope unless they ask for weight.
6. APIs or class methods
In LLD you may show a service, not HTTP. Either is fine.
requestHall(floor, direction) -> accepted
requestCar(elevator_id, floor) -> accepted
status() -> snapshot
step() -> void // the clock
Hall call
Request:
POST /v1/buildings/b_tower/hall-calls
{ "floor": 8, "direction": "DOWN" }
Response (new):
HTTP 202
{ "floor": 8, "direction": "DOWN", "assigned_elevator": "A" }
Response (already pending — Alice then Bob on the same panel):
HTTP 200
{ "floor": 8, "direction": "DOWN", "assigned_elevator": "A", "deduped": true }
A second Down on 8 does not create a second job and does not reassign. The lamp is already lit.
Invalid: Down on floor 1, Up on floor 10, unknown floor. Those are 400.
Car call
Request:
POST /v1/buildings/b_tower/elevators/B/car-calls
{ "floor": 5 }
Response:
HTTP 202
{ "elevator_id": "B", "floor": 5, "car_calls": [5] }
Pressing 5 twice in Car B is a no-op. A car call for a floor the car is sitting on with doors closed becomes “open now,” not a trip around the building.
Status
Request:
GET /v1/buildings/b_tower/status
Response:
HTTP 200
{ "elevators": [
{ "id": "A", "floor": 9, "state": "IDLE", "door": "CLOSED", "car_calls": [], "load": 0 },
{ "id": "B", "floor": 2, "state": "MOVING_UP", "door": "CLOSED", "car_calls": [5], "load": 1 }
],
"pending_hall": [{ "floor": 8, "direction": "DOWN", "assigned": "A" }] }
The lobby display can read this. Assignment still goes through requestHall(), not through a guest staring at the arrows.
step() is not an HTTP API. It is the simulator the interview wants: one shared clock, no Thread per floor.
7. Start with one car that re-scans every tick — show the yo-yo
The first design everyone writes:
function step(car):
targets = all pending hall floors + car.carCalls
if targets is empty:
car.state = IDLE
return
next = closest(car.floor, targets) // SSTF
move one floor toward next
if car.floor == next:
open doors, clear that request
It is easy to draw and easy to break.
t=0 Car at 5. Alice presses 8 DOWN. Target 8. Car goes up.
t=1 Floor 6. Bob presses 2 UP. Closest is now 2. Car reverses.
t=2 Floor 5. Charlie presses 9 DOWN. Closest is 9. Car reverses.
t=3 Alice still on 8. Bob still on 2. The cabin is a yo-yo.
The bug is not “we used a loop.” The bug is re-deciding the destination every tick from a global pile of floors, with no committed direction. Closest-first (SSTF, shortest seek) starves far landings and turns the car around the moment a nearer button lights up.
FCFS is the other naive pile: a queue of requests in arrival order.
queue: (8, DOWN Alice), (2, UP Bob), (10, DOWN)
serve Alice, then ride empty to 2, then empty to 10
No starvation, but the car crosses the building once per person. Fine for a freight lift. Wrong for a lobby at 9 a.m.
SSTF / closest each tick yo-yos; far floors starve
FCFS queue no starve; terrible travel
SCAN / LOOK commit a direction, then reverse
We will use LOOK (the elevator algorithm). People still say SCAN. Name both; implement LOOK.
8. State machine
One machine per car. Do not hide motion in if floor < dest soup.
request / assign
│
▼
┌──────┐
┌───────►│ IDLE │◄──────────────────┐
│ └──┬───┘ │
│ │ start sweep │ sweep empty
│ ▼ │
│ MOVING_UP ◄──reverse──► MOVING_DOWN
│ │ │
│ │ stop at floor │
│ ▼ │
│ DOOR_OPEN ── dwell ──► close, continue
│
│ fault / power / inspect
└──────────► MAINTENANCE ── restore ──► IDLE
IDLE may start a sweep or open in place. MOVING_* may open, reverse when the sweep is empty, idle, or enter MAINTENANCE. DOOR_OPEN is a state of the car, not a second scheduler. The door object is the sub-state (OPENING / OPEN / CLOSING / CLOSED) so a blocked sensor can reopen without inventing ALICE_IS_BOARDING.
CLOSED ── arrive ──► OPENING ──► OPEN ── dwell ──► CLOSING ──► CLOSED
▲ │
└──── obstruction / hold ──────┘
Rules that keep Bob safe:
- A car in
MOVING_UPdoes not becomeMOVING_DOWNuntil the up sweep is empty (no car calls above, no assigned up hall calls ahead). - Opening on 8 going down clears
(8, DOWN)and any car call for 8. It does not clear(8, UP). MAINTENANCEdrops this car’s assignments back to the controller. It does not delete hall calls.
Illegal: IDLE with a non-empty committed sweep, or two cars listed as owner of (8, DOWN).
Capacity is not a state. It is a flag the stop rule reads: if load >= capacity, skip hall stops; still honor car calls so people can get off.
9. SCAN / LOOK — commit a direction
Disk SCAN travels to the end of the platter, then reverses. An elevator that rides empty to floor 10 because “SCAN goes to the end” is a waste of a tick. LOOK reverses at the last request in this direction. That is the elevator algorithm.
Car B, MOVING_UP, floor 2
carCalls = {5}
assigned hall = none
Sweep UP: stop at 5, open, Bob leaves or stays
No more targets ≥ 5 → reverse or idle
Collect every stop that is ahead and compatible, then move.
stopsAhead(car):
if car.direction == UP:
carCalls where floor > car.floor
+ assigned hall calls where dir == UP and floor > car.floor
if car.direction == DOWN:
carCalls where floor < car.floor
+ assigned hall calls where dir == DOWN and floor < car.floor
On arrival at floor:
open doors
remove carCall(this, floor)
if direction == DOWN: remove HallCall(floor, DOWN) // only if assigned to us
if direction == UP: remove HallCall(floor, UP)
If Alice boarded going down and presses 3, that is requestCar(A, 3), added to the current down sweep. She does not become a new hall call.
FCFS orders people. LOOK orders direction. Car idle on 10, all three already pending:
Alice 8 DOWN, Charlie 2 UP, Bob 9 DOWN
FCFS: 10 → 8 (Alice) → 2 (Charlie) → 9 (Bob)
LOOK: 10 → 9 (Bob) → 8 (Alice) → reverse → 2 (Charlie)
(2, UP) is not a DOWN stop; do not open for Charlie on the way down
Once you are going down, you take every down stop you already own. You do not ride to Charlie until that sweep is empty.
Same-floor, opposite direction is two visits:
Floor 8: (8, DOWN) and (8, UP) both pending
A car going DOWN opens, Alice boards, (8, DOWN) dies
(8, UP) stays lit until a car arrives going UP
That is why direction lives on the hall call, not only on the floor number.
10. Two cars — the controller assigns once
One car does not need an assigner. Two cars do. The controller is the only object allowed to write assignment[(floor, dir)].
Default policy: direction-compatible, least extra travel. Idle cars compete on distance. A busy car only receives a hall call it can serve without reversing.
cost(car, HallCall(f, d)):
MAINTENANCE or out of service → ∞
full and this would be a hall pickup → ∞
IDLE:
|car.floor - f| // nearest idle
MOVING_UP:
if d == UP and f >= car.floor: f - car.floor
else: ∞
MOVING_DOWN:
if d == DOWN and f <= car.floor: car.floor - f
else: ∞
DOOR_OPEN:
treat as the direction it will resume,
or as IDLE if the sweep is about to empty
Pick argmin cost. Tie: lower car id, or the car that is already empty. Say the tie-break.
Look-ahead (optional): a car committed UP may be assigned a DOWN call it will serve after it reverses. Cost is “finish the up sweep, then travel to f.” I would not default to that. It is how Car B steals Alice while Bob is still going to 5. Never assign (8, DOWN) to a car that is MOVING_UP and already at floor > 8 — that is an immediate reverse. Look-ahead for a car still below 8 means “extend this up trip to 8, then turn.” Say it if you use it. Default: Car B does not get Alice.
Alice, Car A, Car B
9 Car A IDLE
8 Alice presses DOWN
5 Bob's car call
2 Car B MOVING_UP
cost(A, (8, DOWN)) = |9 - 8| = 1
cost(B, (8, DOWN)) = ∞ // committed UP, default policy
assign A
Car A goes IDLE → MOVING_DOWN, one floor, DOOR_OPEN on 8. Car B continues 2 → 3 → 4 → 5, opens for Bob. Nobody reversed.
If the assigner is sloppy and both cars take the call:
A starts down from 9
B reverses at 3 toward 8
Bob's car call at 5 is now behind a DOWN car
B opens on 8; A also opens on 8
(8, DOWN) cannot be cleared twice without a uniqueness set
That is the parking-lot double book, in a shaft. The fix is the same shape: one claim.
synchronized (controllerLock):
if (8, DOWN) already in pendingHall:
return existing assignment // Bob's second press
pendingHall.add((8, DOWN))
owner = cheapestCompatibleCar((8, DOWN))
if owner: assignment[(8, DOWN)] = owner
return owner
Unassigned is legal. Every step(), the controller retries assignment for hall calls with no owner (the cheap car was full, or in MAINTENANCE). It does not steal a call from a car that is already on the way unless that car faults.
idle nearest simple; ignores a down car already one floor above
compatible + extra travel my default
look-ahead after reverse fewer idle waits; easy to yo-yo on paper
random / round-robin fair, dumb; use only as a tie-break
I would start with compatible + extra travel. I would not open a queueing-theory paper “because two cars.”
11. Request uniqueness
(floor, direction) is a set key, not a queue of passengers.
Alice presses 8 DOWN pendingHall = { (8, DOWN) }
Bob presses 8 DOWN pendingHall = { (8, DOWN) } // same lamp
When Car A opens going down on 8, the hall call is gone. Alice and Bob both board if load allows. If the car has room for one, Alice boards, Bob stays, and you must re-create or keep (8, DOWN) because the landing is still waiting. That is the Senior wrinkle: clearing the call is “we opened for this direction and were not full,” not “we touched floor 8.”
onStop(car, floor):
openedForHall = matching hall call assigned to car
if openedForHall and car.load >= car.capacity:
do not remove the hall call
reassign later
else:
pendingHall.remove(openedForHall)
assignment.remove(openedForHall)
car.carCalls.remove(floor)
Car calls are a set per elevator. Two passengers in B who both want 5 press one button. Uniqueness is (elevator_id, floor).
Idempotency:
requestHall(8, DOWN) twice one pending row
requestCar(B, 5) twice one car call
step() after the call is gone no second open
requestHall is idempotent. step() is not “replay the press.”
12. Tick / step simulation
Interviews want a clock, not new Thread() per floor, per door, and per lamp. Real buildings have a PLC loop. Your whiteboard has step().
One tick does one physical action per car:
Controller.step():
synchronized (lock):
assignUnownedHallCalls()
for car in elevators:
car.tick()
Elevator.tick():
if MAINTENANCE: return
if state == DOOR_OPEN:
if obstruction: reset dwell; return
dwell -= 1
if dwell > 0: return
close door
pickNext()
return
if state == MOVING_UP:
floor += 1
if shouldStop(floor): openAt(floor)
else if stopsAhead() empty: pickNext()
return
if state == MOVING_DOWN:
floor -= 1
if shouldStop(floor): openAt(floor)
else if stopsAhead() empty: pickNext()
return
if state == IDLE:
pickNext()
shouldStop is true when this floor is a car call, or an assigned hall call whose direction matches the car.
pickNext:
if stopsAhead() not empty:
keep direction, state = MOVING_*
else if opposite sweep not empty:
reverse direction, state = MOVING_*
else if controller handed us a new hall call:
face that floor, leave IDLE
else:
state = IDLE
Between 4 and 5 there is no “fractional floor” unless they ask. Power cut mid-tick: treat the car as at floor or floor+1 (pick one), open if you can, enter MAINTENANCE, return its hall assignments.
Why not threads? Two cars advancing on two threads still need the same mutex on pendingHall. You have invented races and gained nothing the interviewer can grade. step() is deterministic: requestHall(8, DOWN), then one tick and A is on 8 with doors open. Car B’s ticks interleave in the same for car loop.
13. Concurrency
Buttons are events. step() is a timer. They share memory.
controllerLock guards:
pendingHall
assignment
each elevator.carCalls, state, floor, load
One mutex on those sets is enough for a bank of eight. Per-car locks plus a hall-set lock is how you deadlock: A holds its lock and waits for hall; the controller holds hall and waits for A.
requestHall / requestCar short critical section, then return
step() one critical section for assign + all ticks
Do not hold the lock while you “sleep 2 seconds for the door.” Door time is dwell ticks inside step().
The race that matters is the parking-lot race:
Alice Controller Bob
| | |
|---- requestHall(8, DOWN) ->| |
| |<-- requestHall(8, DOWN) --|
| | |
| pendingHall does not yet have (8, DOWN) |
| | |
| add + assign A | |
| | add? already present |
| | return deduped, still A |
|<-- 202 assigned A --------| |
| |-------- 200 same A ----->|
If you “check then add” without the lock, both threads insert and both run cheapestCompatibleCar. You can survive that if the set stays a set — but both might also start a second car. The lock makes the claim one operation.
I would not introduce a concurrent queue library to look senior. I would name the mutex and the invariant: a hall call has zero or one owner.
14. Class sketch
Ideas, not a framework dump. Panels share one Controller.
Direction UP | DOWN
HallCall floor, direction
CarCall elevatorId, floor
Door
phase, dwell, obstruction
onTick() / reopen()
Elevator
id, floor, state, direction, load, capacity
carCalls: Set<int>
door: Door
tick()
shouldStop(floor) -> bool
pickNext()
acceptCarCall(floor)
assignHall(HallCall)
releaseAssignments() -> HallCall[] // MAINTENANCE
Controller
elevators: Elevator[]
pendingHall: Set<HallCall>
assignment: Map<HallCall, Elevator>
requestHall(floor, dir)
requestCar(elevatorId, floor)
status()
step()
assignUnownedHallCalls()
cost(elevator, HallCall) -> int
requestHall is the claim in section 10: validate, lock, dedup, add, cheapestCompatible, assign. requestCar never goes through the cost function. The passenger is already inside; add the floor to that car’s set. If the new floor is behind the car, it waits for the reverse — you do not spin the cabin because Bob forgot and pressed 2 while going to 9. Empty-car immediate reverse is a LOOK special case; say it.
Handlers do not set state = MOVING_UP. pickNext() does, from the sets.
15. Tests that matter
Skip getters. Test the invariant.
- Alice 8 DOWN, A idle on 9.
requestHall(8, DOWN). After enoughstep()calls, A is on 8 with doors open, going down. B (on 2,MOVING_UP, car call 5) is not on 8 and has not reversed. - Do not reverse a busy up car. Same fixture. Assert B’s floor is non-decreasing until 5. Bob’s car call is served.
- Dedup. Two
requestHall(8, DOWN).pendingHallsize 1. One owner. - Opposite lamps.
(8, DOWN)and(8, UP)are two rows. A down stop clears only Down. - Full car. A is assigned,
load == capacity. A may pass 8 or open and refuse boarding;(8, DOWN)stays pending. A second car or a later sweep takes it. - Car call here. B idle on 2,
requestCar(B, 2)→DOOR_OPEN, no tour of the building. - LOOK sweep. A going down owns 8 and 3. Charlie presses 9 UP. A does not go to 9 until 3 is done.
- Obstruction / maintenance. Blocked door: stay
DOOR_OPEN, floor unchanged. B inMAINTENANCEreturns hall assignments; they reassign to A.
Test (1) and (2) are the offer. If they only have time for one fixture, use the opening story.
16. Extensions
Stay on objects. Do not add a message bus.
Express / local. A car has serves: Set<int> or a min/max. cost is ∞ when f is not served. The controller is the same. A sky lobby is a floor that is in two banks — two controllers, not one god object.
Destination dispatch. The landing keypad says “I want 27,” not “Down.” The request is DestinationCall(from, to). The controller assigns a car and may skip lighting a shared Down lamp. Inside, car-call buttons can disappear. The state machine does not change; the compatible-direction rule becomes “this car’s planned stop list already includes from on the way to to.” Say that only if they ask. Default remains hall + car.
Weight, fire, zone parking. Kilograms use the same skip-hall rule. Fire: ignore hall calls, go to a designated floor, MAINTENANCE — mention skip. Idle cars that pre-position to the lobby are pickNext on empty sets, not a new product.
17. Final architecture / object diagram
One bank. Many panels. One controller. Many cars.
Floor 8 Down Floor 2 Up Car B panel
| | |
| requestHall | | requestCar
v v v
+------------------------------------------------------+
| Controller |
| requestHall / requestCar / status / step |
| pendingHall Set<(floor, dir)> |
| assignment (floor, dir) → one Elevator |
+-------------------+----------------------------------+
|
+---------------+----------------+
| |
v v
Elevator A Elevator B
floor, state floor, state
carCalls set carCalls set
Door Door
tick / pickNext tick / pickNext
Alice Controller A B
| | | |
|-- requestHall(8, DOWN) ->| | |
| | assign A | |
| |-- assignHall ->| |
| | | |
| |<---- step() ---+---- step() |
| | | 2→3→4→5 |
| | | 9→8 open |
| | clear (8,DOWN) | |
| | | |
| requestCar(A, 3) | |
| | | 8→3 |
Kafka, Redis, and “elevator microservice” are not in this picture. If those words appear, you left LLD. Compare Design a Parking Lot: one object owns the claim. Source of truth is pendingHall + carCalls + state under one lock. Arrows and status() are derived.
18. Interview-ready summary
How to walk through in 10–15 minutes
0–2 min. Alice on 8 Down, A idle on 9, B up from 2 to 5. LLD, not a cloud.
2–4 min. Floors, two cars, hall vs car buttons, capacity, skip emergency.
4–6 min. Entities: HallCall(floor, dir), CarCall, Elevator, Controller, Door.
6–9 min. Naive closest-each-tick yo-yo; LOOK commits a direction; FCFS contrast.
9–12 min. Cost function, A gets Alice, B does not reverse; uniqueness set.
12–15 min. step() clock, one mutex, the two tests, destination dispatch as an extension.
Key decisions to remember
- This is objects and a scheduler, not a distributed product.
- Many cars = many
Elevators + oneController. - A hall call is
(floor, direction), a set key, not a passenger list. - A car call belongs to one cabin and is not re-assigned.
- LOOK / SCAN: finish the sweep, then reverse. Do not re-pick closest every tick.
- Assign the compatible car with least extra travel; idle nearest if nobody is compatible.
- Do not give
(8, DOWN)to a car committedUPpast 8; look-ahead is opt-in and spoken. - Full cars skip hall stops; the hall call stays pending.
- Simulate with
step(), not a thread per floor. - One mutex on the pending sets. A hall call has zero or one owner.
Likely interviewer follow-up questions
- What if Alice and Bob both press Down on 8?
- Why not always send the nearest car?
- How do you stop Car B from turning around for Alice?
- Car is full when it arrives — is the hall call cleared?
- Up and Down both lit on floor 8. One stop or two?
- How would you unit-test this without sleeping?
- Destination dispatch — what changes?
- How do express cars fit?
- Door blocked, then power cut — which state wins?
- What if they ask for a whole skyscraper?
Senior-level points that differentiate the answer
- Name the yo-yo before drawing classes. Classes without a committed direction are a vocabulary list.
- Separate hall calls from car calls; do not store “people on floor 8.”
- Treat LOOK as “stops ahead in this direction,” not “seek to the top floor.”
- Call out look-ahead instead of hiding it inside a magic cost.
- Full-car arrival must not delete the landing request.
step()as the clock: deterministic tests, no floor threads.- Same claim habit as the parking-lot stall: one lock, one owner.
- Scale is more banks, not Kafka.
A 1–2 minute verbal answer
An elevator bank is hall calls, car calls, and cars. A hall call is the pair (floor, direction) — two people pressing Down on 8 share one pending request. Each car is a state machine: idle, moving up, moving down, door open, maintenance. I schedule with LOOK: keep going in the current direction, collect every stop I already own, then reverse. I do not re-pick the closest floor every tick; that yo-yos and strands people. A controller assigns each hall call to at most one car — the idle nearest, or the busy car that can take it with the least extra travel without reversing. I would not give Alice’s Down on 8 to a car committed upward with a passenger for 5. Simulation is a shared step() that moves each car one floor or one door action. Buttons and the clock share one mutex on the pending sets. I would test Alice on 8 Down with an idle car on 9, and assert the busy up car never reverses. A tower is several banks of this same design.
For how this sits next to other LLD and HLD prompts, see Design a Parking Lot, the questions hub, and the System Design Interview Complete Guide.
