Design Elevator System
An elevator is a bus that drives up and down inside a hollow shaft. The bus has one driver, one set of doors, and a clipboard on the dashboard. Riders write floor numbers on the clipboard from inside the cabin, and the lobby buttons add floor numbers to the same clipboard from outside. The driver only has four rules to follow. Never open the doors while the bus is moving. Never move while the doors are open. Always look at the clipboard before deciding which way to drive. Cross a floor off the clipboard the moment the bus stops at it. Get those four rules right and the elevator works. Get one of them wrong and somebody falls down the shaft.

Elisha Otis demonstrated the safety brake at the 1853 New York World's Fair by standing on a platform suspended over a crowd and ordering an assistant to cut the rope. The brake caught and Otis stayed alive, which is the only reason buildings taller than 5 stories exist today. The brake handled the catastrophic failure — a snapped cable — but the rest of the rules came later, one bug at a time. The first elevators were run by a human operator who manually pulled levers, and the lockout between "doors open" and "motor running" was just the operator paying attention. The 1950s automatic elevators that Otis and Westinghouse shipped had to encode the operator's habits as hard mechanical interlocks, because passengers who pressed the wrong button at the wrong moment would otherwise pull the cabin out from under themselves. Schindler and ThyssenKrupp later layered group control on top — the algorithm that decides which of 6 elevators in a high-rise lobby answers your call — but the bottom layer is still the same four rules the 1950s engineers welded into the relay logic.
The elevator we model has 5 floors and one cabin. The clipboard is a list of pending floor numbers in the order they were requested. The driver's mood is one of three things — driving up, driving down, or sitting idle waiting for a call. The doors are open or closed, never in between. Every event the rider can trigger is one of four things — pressing a floor button, the clock ticking forward one floor, the door-open button, or the door-close button.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Direction {
Up,
Down,
Idle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Door {
Open,
Closed,
}
#[derive(Debug, Clone, Copy)]
enum Event {
RequestFloor(u8),
Tick,
DoorOpen,
DoorClose,
}
struct Elevator {
floor: u8,
direction: Direction,
door: Door,
pending: Vec<u8>,
}Each piece of the elevator's state has its own named type so an illegal combination cannot exist. Direction is the driver's mood. Door is the door's position. Event is the list of things the world can ask the elevator to do. The Elevator struct holds the current floor, the current mood, the door's position, and the clipboard — a Vec<u8> of pending floor numbers. Nothing in the cabin moves without going through the door table, which is the handle method.
impl Elevator {
fn new() -> Self {
Self {
floor: 1,
direction: Direction::Idle,
door: Door::Closed,
pending: Vec::new(),
}
}
fn handle(&mut self, event: Event) -> String {
match event {
Event::RequestFloor(target) => {
if target < 1 || target > 5 {
return format!("rejected: floor {target} out of range");
}
if target == self.floor && self.door == Door::Open {
return format!("already at floor {target}, door open");
}
if !self.pending.contains(&target) && target != self.floor {
self.pending.push(target);
}
self.retarget();
format!("queued floor {target}")
}
Event::Tick => {
if self.door == Door::Open {
return "blocked: door open".into();
}
match self.direction {
Direction::Up => {
self.floor += 1;
self.arrive_if_target()
}
Direction::Down => {
self.floor -= 1;
self.arrive_if_target()
}
Direction::Idle => "idle: nothing to do".into(),
}
}
Event::DoorOpen => {
if self.direction != Direction::Idle && !self.pending.is_empty() {
return "blocked: moving between floors".into();
}
self.door = Door::Open;
"door opened".into()
}
Event::DoorClose => {
self.door = Door::Closed;
self.retarget();
"door closed".into()
}
}
}
fn arrive_if_target(&mut self) -> String {
if let Some(pos) = self.pending.iter().position(|f| *f == self.floor) {
self.pending.remove(pos);
self.direction = Direction::Idle;
format!("arrived at floor {}", self.floor)
} else {
format!("passing floor {}", self.floor)
}
}
fn retarget(&mut self) {
match self.pending.first() {
None => self.direction = Direction::Idle,
Some(&next) => {
self.direction = if next > self.floor {
Direction::Up
} else if next < self.floor {
Direction::Down
} else {
Direction::Idle
};
}
}
}
}Read handle event by event. A RequestFloor first checks that the number is in range — floors below 1 or above 5 get rejected, the same way pressing the 47th-floor button in a 30-story building does nothing. If the target is the current floor and the door is already open, the bus is already there and the clipboard does not need a second copy. Otherwise the floor goes onto the clipboard, and retarget looks at the top of the clipboard to decide which way the driver should face. A Tick is the heartbeat that moves the cabin one floor in the current direction — but only if the door is closed. The door-open guard at the top of the Tick arm is one of the four rules turned into code, and the moment that line is gone, riders can hold the door open and watch the floor numbers change underneath them. After the tick, arrive_if_target checks whether the new floor is on the clipboard. If it is, the clipboard entry is crossed off and the driver goes idle until the rider opens the door and presses the next button. DoorOpen has the inverse guard — the doors only open when the cabin is parked, never while a target is queued and the driver is mid-trip. DoorClose shuts the doors and calls retarget because closing the door is the signal that the driver should look at the clipboard again and pick a new direction.
The reason retarget lives in its own function is that two different events need to call it. Adding a new floor request might change the direction. Closing the door after a stop might change the direction. Both paths want the same logic — look at the top of the clipboard, compare it to the current floor, set the direction. Writing that logic in one place means the day somebody changes the rule, every caller picks up the change for free.

The driver in main does not press buttons. It hands the elevator a hardcoded list of events and prints the elevator's full state after each one. Every print shows the event, what the elevator said about it, and the snapshot of the cabin — floor, direction, door, and what is still on the clipboard.
fn snapshot(e: &Elevator) -> String {
let dir = match e.direction {
Direction::Up => "up",
Direction::Down => "down",
Direction::Idle => "idle",
};
let door = match e.door {
Door::Open => "open",
Door::Closed => "closed",
};
format!(
"floor={} dir={} door={} queue={:?}",
e.floor, dir, door, e.pending
)
}
fn run(label: &str, events: &[Event]) {
let mut e = Elevator::new();
println!("--- {label} ---");
println!("start: {}", snapshot(&e));
for event in events {
let outcome = e.handle(*event);
println!("{:?} -> {} | {}", event, outcome, snapshot(&e));
}
println!();
}Three runs through the cabin tell three stories. The first is the happy trip — a rider on floor 1 presses 3, the cabin ticks up twice, the door opens and closes, then the same rider presses 1 and the cabin ticks back down. The second run shows the door-and-motor lockout in action — a rider requests floor 5 and immediately mashes the door-open button while the driver is already pulling away. The third run shows the input guards — a request for floor 9 and a request for floor 0 both bounce off before they ever touch the clipboard.
fn main() {
let up_and_back = [
Event::RequestFloor(3),
Event::Tick,
Event::Tick,
Event::DoorOpen,
Event::DoorClose,
Event::RequestFloor(1),
Event::Tick,
Event::Tick,
Event::DoorOpen,
];
run("rider goes to 3 and back", &up_and_back);
let door_blocks = [
Event::RequestFloor(5),
Event::DoorOpen,
Event::Tick,
Event::DoorClose,
Event::Tick,
Event::Tick,
];
run("door blocks the motor", &door_blocks);
let bad_inputs = [
Event::RequestFloor(9),
Event::RequestFloor(0),
Event::Tick,
];
run("rejected requests", &bad_inputs);
}--- rider goes to 3 and back ---
start: floor=1 dir=idle door=closed queue=[]
RequestFloor(3) -> queued floor 3 | floor=1 dir=up door=closed queue=[3]
Tick -> passing floor 2 | floor=2 dir=up door=closed queue=[3]
Tick -> arrived at floor 3 | floor=3 dir=idle door=closed queue=[]
DoorOpen -> door opened | floor=3 dir=idle door=open queue=[]
DoorClose -> door closed | floor=3 dir=idle door=closed queue=[]
RequestFloor(1) -> queued floor 1 | floor=3 dir=down door=closed queue=[1]
Tick -> passing floor 2 | floor=2 dir=down door=closed queue=[1]
Tick -> arrived at floor 1 | floor=1 dir=idle door=closed queue=[]
DoorOpen -> door opened | floor=1 dir=idle door=open queue=[]
--- door blocks the motor ---
start: floor=1 dir=idle door=closed queue=[]
RequestFloor(5) -> queued floor 5 | floor=1 dir=up door=closed queue=[5]
DoorOpen -> blocked: moving between floors | floor=1 dir=up door=closed queue=[5]
Tick -> passing floor 2 | floor=2 dir=up door=closed queue=[5]
DoorClose -> door closed | floor=2 dir=up door=closed queue=[5]
Tick -> passing floor 3 | floor=3 dir=up door=closed queue=[5]
Tick -> passing floor 4 | floor=4 dir=up door=closed queue=[5]
--- rejected requests ---
start: floor=1 dir=idle door=closed queue=[]
RequestFloor(9) -> rejected: floor 9 out of range | floor=1 dir=idle door=closed queue=[]
RequestFloor(0) -> rejected: floor 0 out of range | floor=1 dir=idle door=closed queue=[]
Tick -> idle: nothing to do | floor=1 dir=idle door=closed queue=[]Walk the second run line by line and the lockout becomes visible. After RequestFloor(5), the clipboard holds [5] and the direction flips to up. The next line is DoorOpen -> blocked: moving between floors — the door-open button did nothing because the elevator already has a target and the driver is mid-trip. The very next Tick moves the cabin to floor 2 with the doors still closed, exactly as the lockout requires. Without the guard in the DoorOpen arm, the door would have swung open at floor 1 while the cabin was about to climb the shaft, and a passenger reaching for a dropped phone would have lost an arm. The compiler is not the thing protecting the rider here — the compiler is happy either way. The guard is what protects the rider, and the state machine is what makes the guard impossible to forget, because every DoorOpen event in the program has to pass through that one match arm.

One question worth answering from the third run. Why does the elevator report idle: nothing to do on the final Tick instead of advancing or panicking? Because both rejected requests left the clipboard empty, the direction stayed idle, and the Tick arm has a third case for the idle mood that just reports the situation and returns. A version that assumed the elevator always has a target would have crashed when the queue was empty, or worse, ticked the floor counter into negative numbers and tried to subtract 1 from a u8, which is a panic in debug and a wraparound to 255 in release. The named Idle direction makes the empty-queue case a real situation the code has to handle, not an edge it can pretend does not exist.
The thing this design cannot do is share a building with a second cabin. The clipboard belongs to one elevator. A real high-rise has 6 cabins answering the same lobby buttons, and the lobby button presses have to be routed to whichever cabin can answer fastest — closest in distance, already moving in the right direction, not already overloaded. That dispatcher is its own state machine sitting on top of the per-cabin one, and it is the next bottleneck — the same SCAN and LOOK disk-arm scheduling algorithms that IBM developed for hard drives in the 1970s are what Schindler and Otis adapted for group control in modern high-rises.