13 — Problem: Elevator System
Understanding the Problem
An elevator system moves people between floors using a fleet of cars. Riders press external buttons on a floor (picking a direction) and internal buttons inside a car (picking a destination). A dispatcher decides which car serves each external request, and each car runs its own state machine — idle, moving, doors opening, doors open, doors closing, and a few exceptional states for overload, emergency, and maintenance. The design has three moving pieces that interact: request modeling, dispatch strategy, and per-car FSM.
What this interview is really testing
Elevator questions look like a scheduling problem, but the interview is not a scheduling-algorithm interview. Nobody expects you to reinvent SCAN or prove optimality. The interviewer is watching whether you can model a control system cleanly: distinguish external from internal requests, isolate the state machine per car, and make the dispatch policy pluggable so a product team can ship a new algorithm without touching the car code. A senior candidate shows extensibility — "here's where we'd plug in destination dispatch, here's where peak-hour policy lives" — not a perfect SCAN implementation.
Clarifying Questions
You: How many cars and how many floors should I design for? Is this a single building or a campus?
Interviewer: Single building. Plan for 4 cars and up to 50 floors. The design should scale to more but don't over-engineer.
You: Internal buttons (inside the car, pick a floor) vs external buttons (on the floor, pick a direction) — are both in scope, and should the two types of requests be modeled differently?
Interviewer: Both in scope, and yes — I want you to decide how to model them. They behave differently.
You: Does each external button indicate a desired direction — up or down — or just "call"?
Interviewer: Direction-aware. Up button and down button on every floor (top floor has down only, bottom has up only).
You: What about an emergency stop, fire service mode, or overload? Are those in scope, or just "mention them"?
Interviewer: In scope. Emergency stop must preempt normal dispatch. Overload prevents the car from moving. Fire mode is extensibility — I just want to hear how you'd plug it in.
You: Express floors or access-controlled floors (e.g., a parking level requires a keycard)?
Interviewer: Extensibility only. Show me where it'd hook in.
You: Service intervals or maintenance mode — can a car be marked out of service?
Interviewer: Yes. Dispatcher must skip cars in maintenance.
You: What are the latency and fairness expectations? How quickly does a button press need to be acknowledged?
Interviewer: Button press → car assigned within ~200 ms. Rider wait time should not exceed ~60 s under normal load, and no floor should be starved indefinitely.
You: One unified request queue or per-car queues? Or are you leaving that to me?
Interviewer: Your call — I want to hear the tradeoff.
You: Do we need to persist state across power loss?
Interviewer: Recovery is extensibility. Core design is in-memory.
Final Requirements
- Functional: External requests (floor + direction), internal requests (destination floor per car), dispatcher that assigns an elevator to each external request, per-car FSM with doors, emergency stop, overload, maintenance mode.
- Non-functional: Assignment latency < 200 ms, no starvation, emergency preempts dispatch, strategy pluggable at runtime.
- Out of scope for core: Destination dispatch, fire service mode, access control, multi-bank buildings, energy optimization, persistence. Discussed as extensions.
Functional Requirements
- A rider on floor F can press up or down; system assigns exactly one car to serve that request.
- A rider inside car C can press any floor button; the car adds it to its own stop list.
- The system has ≥ 1 car, each car has a physical position, direction, and state.
- A car moves floor-by-floor, opens doors when it arrives at a stop, holds doors for a configurable dwell, then closes and continues.
- A car can be placed in maintenance (no new assignments, finishes current, then idles at a home floor).
- Emergency stop halts all motion and parks the car at the nearest floor with doors open.
- Overload prevents the car from closing its doors and departing.
- Floor displays show each car's current floor and direction.
Non-Functional Requirements
| Requirement | Target | Why |
|---|---|---|
| Assignment latency | < 200 ms from press to car assigned | UX; rider expects the chevron to light up instantly. |
| Rider wait time | P95 < 60 s in normal load | Building-code and tenant-SLA territory. |
| Fairness / starvation | Every pending request served within a bounded number of cycles | A high floor should never wait forever while the lobby keeps calling. |
| Emergency response | < 50 ms from signal to brake | Safety. Emergency path bypasses the dispatcher queue entirely. |
| Strategy hot-swap | Replace dispatch algorithm without restarting the controller | Ops can roll out peak-hour policy at 5 PM. |
| Thread-safety | Dispatcher handles concurrent presses from multiple floors and cars | Real building has dozens of buttons pressed per second during rush hour. |
Core Entities and Relationships
| Entity | Responsibility |
|---|---|
Building | Owns the fleet and the dispatcher. Entry point for external requests. |
Elevator | One car. Owns its FSM, current floor, direction, and internal stop list. |
Floor | Floor number + up/down button panels + display. |
ExternalRequest | { floor, direction }. Lives in the dispatcher. Assignment is deferred. |
InternalRequest | { elevator, floor }. Goes directly to that car's stop list. |
Direction | Enum: UP, DOWN, IDLE. |
ElevatorState | Enum: IDLE, MOVING_UP, MOVING_DOWN, DOORS_OPENING, DOORS_OPEN, DOORS_CLOSING, MAINTENANCE, EMERGENCY. |
Dispatcher | Holds pending external requests + a pluggable IDispatchStrategy. Matches requests to cars. |
IDispatchStrategy | Interface. pickElevator(request, elevators) returns the best car. |
FloorDisplay | Observer. Subscribes to elevator state changes, updates the panel. |
Why split internal and external requests into different types? They are fundamentally different events:
- An external request has no destination yet; the rider has only declared intent to travel. It must be queued centrally and assigned to a car.
- An internal request is a concrete destination from someone already aboard a specific car. It can never be re-assigned to another car — the rider is physically there.
Collapsing both into one Request type with an optional elevator field is the classic junior mistake. It forces every piece of dispatch code to branch on "is this assigned yet?" and smuggles the type distinction into runtime checks. Two types, one interface — let the type system carry the difference.
Interfaces
enum Direction {
UP = "UP",
DOWN = "DOWN",
IDLE = "IDLE",
}
enum ElevatorState {
IDLE = "IDLE",
MOVING_UP = "MOVING_UP",
MOVING_DOWN = "MOVING_DOWN",
DOORS_OPENING = "DOORS_OPENING",
DOORS_OPEN = "DOORS_OPEN",
DOORS_CLOSING = "DOORS_CLOSING",
MAINTENANCE = "MAINTENANCE",
EMERGENCY = "EMERGENCY",
}
interface IRequest {
readonly id: string;
readonly createdAt: number; // epoch ms — used for starvation checks
}
interface ExternalRequest extends IRequest {
readonly kind: "external";
readonly floor: number;
readonly direction: Direction; // UP or DOWN only, never IDLE
}
interface InternalRequest extends IRequest {
readonly kind: "internal";
readonly elevatorId: string;
readonly floor: number;
}
interface IElevator {
readonly id: string;
readonly currentFloor: number;
readonly direction: Direction;
readonly state: ElevatorState;
readonly stops: ReadonlySet<number>; // floors this car will stop at
addInternalStop(floor: number): void;
assignExternal(req: ExternalRequest): void;
step(): void; // one tick of the FSM
emergencyStop(): void;
setMaintenance(on: boolean): void;
isAvailable(): boolean; // not maintenance, not emergency
}
interface IDispatchStrategy {
readonly name: string;
pickElevator(req: ExternalRequest, cars: readonly IElevator[]): IElevator | null;
}
interface IFloorDisplay {
onElevatorUpdate(car: IElevator): void;
}Why pickElevator returns IElevator | null? If every car is in maintenance or emergency, there's nothing to pick. The dispatcher holds the request and retries on the next tick. Returning null makes "no assignment" a first-class outcome instead of a thrown exception (exceptions for flow control is a code smell in control systems where null is an expected state).
Class Diagram
+-----------------+
| Building |
+-----------------+
| - elevators |
| - floors |
| - dispatcher |
+-----------------+
| |
| 1..* | 1
v v
+------------+ +---------------+ +-------------------+
| Elevator | | Dispatcher |<>------>| IDispatchStrategy |<---+
+------------+ +---------------+ +-------------------+ |
| - id | | - pending | ^ |
| - floor | | - strategy | | |
| - dir | +---------------+ +----------+---------+ |
| - state | | pickForReq() | | NearestCarStrategy | |
| - stops | | tick() | +--------------------+ |
+------------+ +---------------+ | LookStrategy |------+
| step() | +--------------------+
| assign() |<----observes----+
+------------+ |
|
+----------------+
| FloorDisplay |
+----------------+
| onElevatorUpd |
+----------------+
+------------------+ +------------------+
| ExternalRequest | | InternalRequest |
+------------------+ +------------------+
| floor, direction | | elevatorId,floor |
+------------------+ +------------------+Elevator FSM (states and transitions):
setMaintenance(true)
+---------------------------------------+
| v
+------------+ stops empty +-------------+
| IDLE |<----------------| MAINTENANCE |
+------------+ +-------------+
| ^ ^
dispatch | | setMaintenance(true)
or stop | | (after current stop)
v | |
+------------+ arrived at +-----------------+
| MOVING_UP |--------------->| DOORS_OPENING |
+------------+ stop +-----------------+
^ | |
| | (keep moving) | doors reached open
| v v
+------------+ +---------------+
| MOVING_DOWN|<----------------| DOORS_OPEN |
+------------+ next stop +---------------+
^ |
| | dwell elapsed &
| | not overloaded
| v
| doors shut +------------------+
+------------------------| DOORS_CLOSING |
+------------------+
Any state ---emergencyStop()---> EMERGENCY
|
| cleared
v
IDLEClass Design
Elevator — the state machine
Each car runs an FSM driven by a step() tick. The tick function looks at (state, stops, direction) and decides the next state. No dispatch logic lives in the car — it just executes stops it has been given.
Why keep the FSM inside the car and not centrally? The alternative is a god-controller that knows every car's state. That design couples the scheduling policy to physical constraints (door timing, motor ramp-up). Keeping the FSM local means:
- Adding a new car model with different timing is a subclass, not a controller change.
- The dispatcher reads
car.statethrough a narrow interface and never mutates it. - Testing the FSM is isolated — no fake dispatcher needed.
Dispatcher — request routing
The dispatcher holds a queue of unassigned external requests and a reference to a strategy. On each tick it walks the queue and asks the strategy for the best car. If a car is returned, assign and remove; otherwise leave in queue.
Why a queue instead of immediate assignment? Under load, pressing a button when all cars are busy should not drop the request. The queue decouples button press from car availability. Under no load, the dequeue happens on the very next tick — indistinguishable from immediate assignment, without the special case.
Strategy — the pluggable policy
Two strategies in the core design: NearestCarStrategy (greedy) and LookStrategy (direction-preserving). The Building is constructed with a strategy; swapping is a single setter call.
Why SCAN/LOOK beats greedy for high-rise? Greedy picks the physically closest car regardless of direction. In a high-rise with lobby traffic, this thrashes — a car moving up to floor 30 with passengers inside gets yanked to answer a lobby call because it happens to be closest. The passengers already aboard see their trip hijacked. SCAN/LOOK keeps a car moving in one direction until it has no more stops that way, then reverses. It preserves rider intent at the cost of some individual-request latency. For buildings with > 10 floors and > 2 cars, LOOK wins on aggregate wait time and on rider satisfaction (nobody likes being taken the wrong direction).
Worked example. Suppose cars A at floor 3 moving UP to floor 15 (3 riders aboard), B idle at floor 10. Lobby call on floor 2 (UP). Greedy says "A is closer to floor 2 than B is — send A." But A is going the other way with 3 riders. LOOK says "A is moving UP past floor 2 — can't serve it without reversal. B is idle — send B." Net effect: the 3 riders on A see no disruption, floor-2 rider waits ~8 seconds for B, total system wait time is lower. Greedy would have yanked A down, stranded the 3 riders, then sent A back up — doubling total work.
Key Methods
class Elevator implements IElevator {
readonly id: string;
currentFloor: number;
direction: Direction = Direction.IDLE;
state: ElevatorState = ElevatorState.IDLE;
stops: Set<number> = new Set();
private readonly minFloor: number;
private readonly maxFloor: number;
private doorDwellMs: number = 3000;
private doorTimer: number = 0;
private overloaded: boolean = false;
private listeners: IFloorDisplay[] = [];
constructor(id: string, minFloor: number, maxFloor: number, startFloor: number = minFloor) {
this.id = id;
this.minFloor = minFloor;
this.maxFloor = maxFloor;
this.currentFloor = startFloor;
}
addInternalStop(floor: number): void {
if (floor < this.minFloor || floor > this.maxFloor) return;
if (!this.isAvailable()) return;
this.stops.add(floor);
// If idle, start moving toward it
if (this.state === ElevatorState.IDLE) {
this.direction = floor > this.currentFloor ? Direction.UP : Direction.DOWN;
if (floor === this.currentFloor) this.transition(ElevatorState.DOORS_OPENING);
else this.transition(floor > this.currentFloor ? ElevatorState.MOVING_UP : ElevatorState.MOVING_DOWN);
}
}
assignExternal(req: ExternalRequest): void {
this.stops.add(req.floor);
if (this.state === ElevatorState.IDLE) {
this.direction = req.floor > this.currentFloor ? Direction.UP : req.floor < this.currentFloor ? Direction.DOWN : req.direction;
if (req.floor === this.currentFloor) this.transition(ElevatorState.DOORS_OPENING);
else this.transition(this.direction === Direction.UP ? ElevatorState.MOVING_UP : ElevatorState.MOVING_DOWN);
}
}
step(): void {
switch (this.state) {
case ElevatorState.MOVING_UP:
case ElevatorState.MOVING_DOWN:
this.stepMoving();
break;
case ElevatorState.DOORS_OPENING:
this.transition(ElevatorState.DOORS_OPEN);
this.doorTimer = this.doorDwellMs;
break;
case ElevatorState.DOORS_OPEN:
this.doorTimer -= 1000; // one tick = 1 s
if (this.doorTimer <= 0 && !this.overloaded) {
this.transition(ElevatorState.DOORS_CLOSING);
}
break;
case ElevatorState.DOORS_CLOSING:
this.stops.delete(this.currentFloor);
if (this.stops.size === 0) {
this.direction = Direction.IDLE;
this.transition(ElevatorState.IDLE);
} else {
const next = this.pickNextDirection();
this.direction = next;
this.transition(next === Direction.UP ? ElevatorState.MOVING_UP : ElevatorState.MOVING_DOWN);
}
break;
case ElevatorState.IDLE:
case ElevatorState.MAINTENANCE:
case ElevatorState.EMERGENCY:
// Nothing to do.
break;
}
}
private stepMoving(): void {
this.currentFloor += this.direction === Direction.UP ? 1 : -1;
if (this.stops.has(this.currentFloor)) {
this.transition(ElevatorState.DOORS_OPENING);
}
}
private pickNextDirection(): Direction {
// LOOK: keep current direction if there are stops that way, else reverse.
const current = this.direction;
const hasAhead = [...this.stops].some(f =>
current === Direction.UP ? f > this.currentFloor : f < this.currentFloor,
);
if (hasAhead) return current;
const hasBehind = [...this.stops].some(f =>
current === Direction.UP ? f < this.currentFloor : f > this.currentFloor,
);
return hasBehind ? (current === Direction.UP ? Direction.DOWN : Direction.UP) : Direction.IDLE;
}
emergencyStop(): void {
this.state = ElevatorState.EMERGENCY;
this.direction = Direction.IDLE;
this.notify();
}
setMaintenance(on: boolean): void {
if (on) {
// Finish current stop list? Product decision. Here: stop accepting new, drain existing.
this.state = ElevatorState.MAINTENANCE;
this.stops.clear();
this.direction = Direction.IDLE;
} else if (this.state === ElevatorState.MAINTENANCE) {
this.state = ElevatorState.IDLE;
}
this.notify();
}
setOverloaded(on: boolean): void {
this.overloaded = on;
}
isAvailable(): boolean {
return this.state !== ElevatorState.MAINTENANCE && this.state !== ElevatorState.EMERGENCY;
}
subscribe(l: IFloorDisplay): void {
this.listeners.push(l);
}
private transition(next: ElevatorState): void {
this.state = next;
this.notify();
}
private notify(): void {
for (const l of this.listeners) l.onElevatorUpdate(this);
}
}Dispatch strategies
class NearestCarStrategy implements IDispatchStrategy {
readonly name = "nearest-car";
pickElevator(req: ExternalRequest, cars: readonly IElevator[]): IElevator | null {
let best: IElevator | null = null;
let bestDist = Infinity;
for (const c of cars) {
if (!c.isAvailable()) continue;
const d = Math.abs(c.currentFloor - req.floor);
if (d < bestDist) {
bestDist = d;
best = c;
}
}
return best;
}
}
class LookStrategy implements IDispatchStrategy {
readonly name = "look";
pickElevator(req: ExternalRequest, cars: readonly IElevator[]): IElevator | null {
// Prefer cars already moving in the rider's direction and passing the rider's floor.
let best: IElevator | null = null;
let bestScore = -Infinity;
for (const c of cars) {
if (!c.isAvailable()) continue;
const score = this.score(c, req);
if (score > bestScore) {
bestScore = score;
best = c;
}
}
return best;
}
private score(car: IElevator, req: ExternalRequest): number {
const dist = Math.abs(car.currentFloor - req.floor);
const base = -dist; // closer is better
if (car.state === ElevatorState.IDLE) return base - 2; // idle cars a bit disfavored
if (car.direction === req.direction) {
const onTheWay =
(req.direction === Direction.UP && car.currentFloor <= req.floor) ||
(req.direction === Direction.DOWN && car.currentFloor >= req.floor);
if (onTheWay) return base + 100; // strongly prefer in-direction pickups
}
// Car going the wrong way — will have to finish and come back. Heavy penalty.
return base - 50;
}
}Dispatcher
class Dispatcher {
private pending: ExternalRequest[] = [];
private strategy: IDispatchStrategy;
private readonly cars: readonly IElevator[];
private readonly starvationThresholdMs = 60_000;
constructor(cars: readonly IElevator[], strategy: IDispatchStrategy) {
this.cars = cars;
this.strategy = strategy;
}
setStrategy(s: IDispatchStrategy): void {
this.strategy = s; // hot-swap safe: next tick uses new policy, in-flight assignments unchanged
}
submit(req: ExternalRequest): void {
this.pending.push(req);
}
tick(now: number): void {
const still: ExternalRequest[] = [];
// Prioritize starving requests to the front.
this.pending.sort((a, b) => a.createdAt - b.createdAt);
for (const req of this.pending) {
const age = now - req.createdAt;
// If a request is starving, bypass strategy and pick any available car closest.
const strategy = age > this.starvationThresholdMs ? new NearestCarStrategy() : this.strategy;
const car = strategy.pickElevator(req, this.cars);
if (car) {
car.assignExternal(req);
} else {
still.push(req);
}
}
this.pending = still;
}
}Why the starvation override bypasses the current strategy? LOOK can legitimately leave a high-floor down-call waiting if every car keeps getting routed to busier floors. A starving request has waited long enough that fairness now dominates throughput. Temporarily switching to "nearest car, regardless of direction" guarantees it gets served on the next tick.
Why is the strategy setter safe to call any time? Strategies are stateless — they only read (request, cars) and return an IElevator | null. No long-running state, no mid-operation transaction. The next tick() picks up the new strategy atomically.
Design Decisions & Tradeoffs
Dispatch: nearest vs LOOK vs full optimization
| Strategy | Mean wait | P95 wait | Rider hijacking | Complexity | Good for |
|---|---|---|---|---|---|
| Nearest car (greedy) | Low at low load | Bad at high load | Frequent — cars get yanked | O(N) per request | Small buildings, low traffic. Easy to explain. |
| SCAN/LOOK (direction-preserving) | Moderate | Best under load | Rare | O(N) per request | High-rises, lobby-heavy traffic. |
| Full optimization (ILP / ML) | Optimal in theory | Optimal | None | O(N × M × K) + tuning | Rarely justified. Requires demand forecasting. |
Greedy is the "obvious" first answer and it's wrong for anything above maybe 6 floors. The moment two cars and a busy lobby exist, greedy thrashes. LOOK is the industry default for a reason.
Per-car queue vs central dispatcher
| Approach | Pros | Cons |
|---|---|---|
| Per-car queue only (no dispatcher) | Simple. Each car self-directs from button inputs on its own panel. | No global view — you can't assign external requests efficiently. Two cars may converge on the same floor. |
| Central dispatcher + per-car stops (chosen) | Global view for external requests. Cars still own their own stop list for internals. | Single choke point — dispatcher must be thread-safe and fast. |
| Pure central queue (dispatcher owns every stop) | Maximum re-planning flexibility. | Dispatcher is tightly coupled to car internals; hard to evolve the FSM. |
Chosen: hybrid. Dispatcher owns unassigned external requests. Car owns assigned stops (both internal and already-assigned external). This is the Unix principle — the dispatcher is the scheduler; the car is the executor.
Request modeling: 1 unified queue vs per-direction split
| Approach | Pros | Cons |
|---|---|---|
| Unified queue (chosen) | Single place to reason about ordering and starvation. Strategy decides direction semantics. | Strategy must re-derive direction each tick. |
| Per-direction split (up queue + down queue) | Direction is a first-class index; SCAN/LOOK is almost free. | Two queues to manage. Starvation logic duplicated. Breaks when a strategy ignores direction (e.g., destination dispatch). |
A unified queue with direction as a field on the request is more flexible. If later you add destination dispatch (where direction is derived, not chosen), the per-direction split falls apart.
Patterns Used
| Pattern | Where | Why |
|---|---|---|
| State | Elevator FSM | Each state has different transition rules. Encoding them as explicit states prevents invalid combinations (can't move while doors are open). |
| Strategy | IDispatchStrategy | Dispatch policy changes independently of car mechanics. Swap at runtime for peak hours. |
| Command | ExternalRequest / InternalRequest as plain objects | Requests are self-contained, queueable, loggable. Easy to replay for debugging ("why did car 3 get this call?"). |
| Observer | IFloorDisplay subscribes to Elevator state changes | Displays don't poll; cars push updates. Multiple displays (floor panel, building dashboard, mobile app) can subscribe without the car knowing they exist. |
| Chain of Responsibility | Emergency → maintenance → normal dispatch | The dispatcher checks for emergency first, skips cars in maintenance, then runs the strategy. Each step can stop the chain. |
| Facade | Building wraps Dispatcher + fleet + floors | Single public entry point. Hides the tick loop, the listener wiring, the strategy injection. |
Concurrency Considerations
Real elevator systems are deeply concurrent — dozens of button presses per second in rush hour, cars running on independent control loops, displays pushing updates to riders.
Dispatcher thread-safety. The submit method is called from many threads (one per floor's button panel, plus internal calls from the app). The pending queue must be thread-safe. Options:
- Single mutex around
pending. Simplest. Contention is low because each call is O(1) add or O(N) drain on tick. Good default. - Lock-free concurrent queue. Overkill for this workload; button presses are rare compared to lock acquisition cost.
- Single-threaded actor. Dispatcher runs on its own thread; all submissions go through a message queue. Clean but adds latency.
Per-elevator state machine. Each car should run its FSM on its own event loop / thread. The FSM itself is single-threaded — step() runs serially, so internal state transitions never race. External calls (assignExternal, emergencyStop, setMaintenance) must synchronize with the tick.
Emergency path. Emergency must bypass the dispatcher queue and any normal lock. Use a volatile flag per car that the FSM checks at the top of every step(). Setting the flag is O(1) and lock-free.
class Elevator {
private emergencyFlag: AtomicBoolean;
step(): void {
if (this.emergencyFlag.get()) {
this.state = ElevatorState.EMERGENCY;
return;
}
// ...normal tick
}
}Request ordering. Two riders pressing up and down on the same floor at the same millisecond produce two independent requests. They go through the dispatcher's normal queue and are assigned by the strategy — which may assign them to the same car (if it passes through the floor twice) or different cars. Use the request createdAt field to break ordering ties.
Scale & Extensibility
Multiple banks of elevators
High-rises often split the fleet — low-rise bank (floors 1–30), high-rise bank (floors 1, 31–60), maybe a garage bank. Each bank is a separate Dispatcher instance with its own strategy. A Building now owns N banks instead of one fleet. An external request on floor 25 routes to the low-rise bank; floor 45 to the high-rise. The strategy-and-FSM model carries over unchanged — banks just partition the fleet.
Destination dispatch
Instead of "up button" on the floor, the rider enters their destination on a lobby keypad before boarding. The system assigns a car optimized for everyone going the same way. Modeling change: ExternalRequest now carries a destination. The dispatcher groups requests by shared destination and assigns them to the same car. The FSM is unchanged. The strategy is entirely new (DestinationDispatchStrategy) and slots into the existing interface.
Access-controlled floors
Some floors require a keycard. Model as a policy check inside addInternalStop — if the car is not authorized for that floor by this rider, reject the button press with feedback. Authorization lives in a separate IAccessPolicy that the car queries. Keeps the FSM clean; adds a decorator layer.
Peak-hour strategies
At 9 AM, most traffic goes up from the lobby; at 5 PM it reverses; mid-day is random. A TimeAwareStrategy wraps two inner strategies and swaps based on the clock. Equivalent to setStrategy called by a cron job.
Energy optimization
Park idle cars on floors predicted to have near-term demand (lobby in mornings, mid-floors at lunch). An idle-car-parking policy is orthogonal to dispatch — it decides where a car goes when it has no stops. Add a method getRestFloor() on the strategy, called when a car transitions to IDLE.
Observability
Emit structured events on every state transition and every dispatch decision. A separate consumer aggregates wait-time histograms, per-floor traffic heatmaps, starvation counts. Real buildings do this; it's how property managers detect failing cars before they fail.
interface ElevatorEvent {
timestamp: number;
carId: string;
kind:
| "state-change"
| "stop-added"
| "stop-reached"
| "dispatch-assigned"
| "dispatch-rejected"
| "emergency-triggered";
payload: Record<string, unknown>;
}
interface IEventSink {
emit(event: ElevatorEvent): void;
}The dispatcher and every car hold an IEventSink (logger, metrics system, Kafka topic — the interface doesn't care). Observability is orthogonal to correctness; adding it later is a matter of injecting a non-noop sink. Starting with a NullEventSink means the production code path is identical in dev and prod.
Edge Cases
- Request for the current floor. Rider on floor 5 presses up; car 2 is already on floor 5 idle.
assignExternalseesreq.floor === currentFloorand jumps straight toDOORS_OPENING. Skip the move state entirely. - All elevators in maintenance. Strategy returns
null. Dispatcher holds the request. When a car exits maintenance, the next tick assigns it. The request's age counts toward starvation — if maintenance is long, the request will be logged as starving and surfaced to ops. - Emergency stop mid-travel. Car is MOVING_UP between floors.
emergencyStop()sets state to EMERGENCY; nextstep()does nothing. Real hardware would trigger brakes; in software we park at the next safe floor and open doors. - Overload — doors won't close.
doorTimerreaches 0 butoverloaded === true. FSM stays inDOORS_OPEN. Riders get a visual/audio prompt; once a rider exits, overload clears and the FSM resumes its normal close. - Stuck doors. Separate from overload — a physical obstruction. Detected by a timeout in
DOORS_CLOSINGthat never completes. Transition toEMERGENCY, alert ops. - Concurrent external requests from two floors, opposite directions. Floor 10 presses UP; floor 20 presses DOWN. Dispatcher queues both. Strategy assigns — LOOK may send the same car if it's heading up past 10 and can reverse after 20. Two cars if available.
- Starvation of high floors. Described above. Starvation threshold overrides the strategy with a nearest-car fallback.
- Power outage recovery. On restart, every car does a homing pass to the nearest floor (physical hardware responsibility). The software comes up with all cars in
IDLE, stop lists empty, dispatcher queue empty. Pending requests are lost — this is why real systems persist the queue to a durable log. - A request for a floor below
minFlooror abovemaxFloor. Reject ataddInternalStop— silently or with a "button not valid for this bank" indicator. - Car reaches its last stop and direction has no more pending. FSM transitions to
IDLE, direction toIDLE. On the next external assignment, direction is set based on the request's floor relative to the car.
Follow-up Questions
Q: How would you add destination dispatch? Extend ExternalRequest with a destination field. Add a DestinationDispatchStrategy that groups pending requests by origin-direction-destination tuples and assigns them to the same car to pool trips. The FSM is unchanged. UI change: lobby kiosk replaces direction buttons.
Q: How do you prevent starvation? Tag every request with createdAt. On each dispatch tick, check if any pending request has been waiting beyond a threshold (e.g., 60 s). If so, override the configured strategy with nearest-car-wins for that request. Keeps the common case (LOOK) efficient while guaranteeing a worst-case bound.
Q: How do you handle emergency fire mode? Fire mode is a system-wide signal. On receipt: every car finishes its current motion, clears its stops, descends to ground, opens doors, disables external dispatch. Model as a BuildingMode enum (NORMAL, FIRE, EARTHQUAKE) that the dispatcher and cars both check. Mode transitions are themselves an FSM at the building level.
Q: How do you add express elevators? Express cars serve only a subset of floors (lobby + floors 30+). Represent as per-car allowedFloors: Set<number>. Strategy filters candidate cars by allowedFloors.has(req.floor) before scoring. Internal requests respect the same set (button for floor 15 in an express car is dark).
Q: How do you load-test the dispatch algorithm? Build a simulator that feeds synthesized traffic patterns (uniform, lobby-spike, end-of-day-spike) into the dispatcher and records wait times per request. Run with different strategies and building shapes. Key metrics: mean wait, P95 wait, starvation count, mean energy use. Golden-set replays of real-building logs are the gold standard.
Q: How do you evolve the strategy without restarts (hot-swap)?dispatcher.setStrategy(new LookStrategy()) at any time. Because strategies are stateless and pure, the swap is safe. In-flight assignments aren't affected — only future pickElevator calls see the new strategy. Add a feature flag to roll out gradually (e.g., 10 % of buildings get the new strategy this week).
Q: How do you add energy optimization? Two hooks. First, getRestFloor(time, historicalTraffic) returns a floor for idle cars to migrate to (saves energy by positioning near expected demand). Second, group motion — when two cars could answer a request, prefer the one whose move consumes less energy (e.g., descending with counterweight). Energy policy is its own IEnergyPolicy interface that the strategy consults.
SDE2 vs SDE3 — How the Bar Rises
| Dimension | SDE2 (expected) | SDE3 (expected) |
|---|---|---|
| Request modeling | Distinguishes external from internal; one request class with a flag. | Two types with a common interface; explains why the type distinction matters for the dispatch contract. |
| Dispatch | Implements nearest-car. Mentions SCAN when asked. | Proposes LOOK as the default, explains why nearest-car fails on high-rise; structures strategy as an interface from the start. |
| State machine | IDLE / MOVING / DOORS_OPEN — 3–4 states. | 8 states including DOORS_OPENING / DOORS_CLOSING, MAINTENANCE, EMERGENCY; explains why transient door states exist (you can't open and close instantly). |
| Strategy extensibility | Strategy is baked into dispatcher. | Strategy is injected; hot-swap discussed; peak-hour and destination dispatch slotted in as concrete examples. |
| Fairness / starvation | Not mentioned unprompted. | First-class: starvation threshold, aging, override of strategy when starving. Cites P95 SLA. |
| Concurrency | "Use a lock." | Specifies: single mutex on pending queue, per-car single-threaded FSM, atomic flag for emergency, why emergency path bypasses normal locks. |
| Failure modes | Handles the happy path and maybe maintenance. | Power-outage recovery, stuck doors, overload-won't-close, all-cars-unavailable graceful degradation. Distinguishes safety-critical from operational failures. |
| Observability | Not mentioned. | Events on every transition; per-floor wait-time histograms; starvation counter; connects it to how ops catches failing cars early. |
| Scope discipline | Builds what's asked; may over-engineer once. | Explicitly scopes out CRDTs, full optimization, multi-bank for the core and lists them as extensions with clear integration points. |