1701 words, 9 min read

A lift looks simple from the outside: press a button, wait, and eventually it arrives. Internally, however, a lift controller is a scheduling problem.

With multiple lifts and multiple passengers, the controller needs to decide which lift should handle which request, while trying to minimise waiting time, travel time, and unnecessary stops.

Let's build a simplified version in Python.

Start with a single lift

A lift needs to keep track of its current state:

from dataclasses import dataclass, field


@dataclass
class Lift:
    floor: int
    direction: str | None = None
    targets: set[int] = field(default_factory=set)

A simple controller can use the LOOK scheduling algorithm to select the next floor.

LOOK works like this:

  • Continue in the current direction while there are targets in that direction.
  • Visit the closest target in that direction.
  • When there are no more targets in that direction, reverse.
  • If there are no targets at all, become idle.
def next_target(lift: Lift) -> int | None:
    if not lift.targets:
        lift.direction = None
        return None

    if lift.direction is None:
        lift.direction = (
            "up"
            if min(lift.targets) > lift.floor
            else "down"
        )

    if lift.direction == "up":
        above = [
            floor
            for floor in lift.targets
            if floor > lift.floor
        ]

        if above:
            return min(above)

        lift.direction = "down"

    if lift.direction == "down":
        below = [
            floor
            for floor in lift.targets
            if floor < lift.floor
        ]

        if below:
            return max(below)

        lift.direction = "up"

    # If we get here, there are targets but none in the
    # current direction. Pick the closest one and establish
    # the corresponding direction.
    target = min(
        lift.targets,
        key=lambda floor: abs(floor - lift.floor),
    )

    lift.direction = (
        "up" if target > lift.floor else "down"
    )

    return target

For example:

Lift:       5
Requests:   2, 4, 6, 8, 10
Direction:  up

The lift visits:

5 → 6 → 8 → 10 → 8 → 6 → 4 → 2

However, that example exposes an important implementation detail: once a target has been served, it must be removed from the target set.

A simple simulation might therefore look like this:

def serve_next(lift: Lift) -> None:
    target = next_target(lift)

    if target is None:
        return

    lift.floor = target
    lift.targets.remove(target)

Now the route is correctly:

5 → 6 → 8 → 10 → 4 → 2

The lift continues upwards until there are no targets above it, then reverses direction.

A simpler implementation

We can make the LOOK behaviour more explicit by separating target selection from state changes:

def next_target(lift: Lift) -> int | None:
    if not lift.targets:
        return None

    if lift.direction == "up":
        above = sorted(
            floor for floor in lift.targets
            if floor > lift.floor
        )

        if above:
            return above[0]

        lift.direction = "down"

    elif lift.direction == "down":
        below = sorted(
            floor for floor in lift.targets
            if floor < lift.floor
        )

        if below:
            return below[-1]

        lift.direction = "up"

    # The lift was idle, or has just changed direction.
    if lift.direction == "up":
        above = [
            floor for floor in lift.targets
            if floor > lift.floor
        ]
        if above:
            return min(above)

    if lift.direction == "down":
        below = [
            floor for floor in lift.targets
            if floor < lift.floor
        ]
        if below:
            return max(below)

    return None

The important distinction from a naive implementation is that LOOK does not choose the globally closest floor.

Given:

Current floor: 5

Targets:
4
6
10

and direction up, the next target is:

6

not:

4

Even though both are equally close, the lift is already travelling upwards.

After serving 6 and 10, it reverses:

5 → 6 → 10 → 4

That's the defining behaviour of LOOK.

Hall requests are different

There are actually two kinds of requests.

A passenger inside the lift says:

I want to go to floor 10.

A passenger outside says:

I'm on floor 5 and want to go up.

The second request also has a direction.

@dataclass
class HallRequest:
    floor: int
    direction: str  # "up" or "down"

This matters because a lift travelling upwards shouldn't necessarily stop for someone waiting to travel downwards.

For example, if the lift is travelling:

2 → 5 → 8 → 10

a request on floor 4 for a downward journey doesn't necessarily belong on that route.

The controller should generally prioritise requests matching its current direction.

Multiple lifts

Now the problem gets more interesting.

Imagine three lifts:

Lift A: floor 2, going up
Lift B: floor 8, idle
Lift C: floor 15, going down

Someone on floor 6 presses the up button.

The obvious solution is to choose the closest lift. But that's not necessarily optimal.

Instead, we can give every lift a score.

def score(lift: Lift, request: HallRequest) -> float:
    distance = abs(lift.floor - request.floor)

    if lift.direction == request.direction:
        direction_penalty = 0
    elif lift.direction is None:
        direction_penalty = 2
    else:
        direction_penalty = 10

    return distance + direction_penalty

We can then select the lowest-scoring lift:

def assign_lift(
    lifts: list[Lift],
    request: HallRequest,
) -> Lift:
    return min(lifts, key=lambda lift: score(lift, request))

This is already considerably better than simply selecting the nearest lift.

Simulating the cost

A more sophisticated controller doesn't just ask:

How far away is this lift?

It asks:

How much additional work will this request cause?

Consider a lift currently at floor 3 with these destinations:

7, 10, 15

A new passenger wants to go from floor 5 to floor 12.

The existing route might be:

3 → 7 → 10 → 15

Adding the passenger changes it to:

3 → 5 → 7 → 10 → 12 → 15

The difference between those routes is the cost of accepting the request.

We can model this with a cost function:

def request_cost(
    waiting_time: float,
    travel_time: float,
    additional_stops: int,
    direction_changes: int,
) -> float:
    return (
        waiting_time * 5
        + travel_time
        + additional_stops * 2
        + direction_changes * 10
    )

The weights are deliberately configurable.

For example, we might consider waiting five times more important than travel time because making somebody wait for a lift is generally worse than adding a few seconds to the journey of someone already inside.

Destination dispatch

Modern lift systems can go one step further.

Instead of pressing:

↑

and later selecting a destination inside the lift, the passenger enters the destination immediately:

Floor 5 → Floor 17

The controller therefore knows the complete journey before assigning a lift.

That allows it to group passengers with similar destinations.

For example:

3 → 17
4 → 18
5 → 16
6 → 17

could potentially be handled by the same lift.

The dispatcher can therefore optimise the entire route instead of treating every passenger as an independent request.

Separating the dispatcher from the lift

A useful architecture is to separate two responsibilities.

The dispatcher decides:

Which lift should handle this request?

The lift controller decides:

Which floor should I visit next?

In Python, that could look like:

class Dispatcher:
    def __init__(self, lifts: list[Lift]):
        self.lifts = lifts

    def assign(self, request: HallRequest) -> Lift:
        return min(
            self.lifts,
            key=lambda lift: score(lift, request),
        )

The lift itself can remain responsible for its own state:

class LiftController:
    def __init__(self, lift: Lift):
        self.lift = lift

    def add_target(self, floor: int) -> None:
        self.lift.targets.add(floor)

    def next_floor(self) -> int | None:
        return next_target(self.lift)

This separation makes the system much easier to reason about and test.

Model it as a state machine

A real controller would also have explicit states:

from enum import Enum


class LiftState(Enum):
    IDLE = "idle"
    MOVING_UP = "moving_up"
    MOVING_DOWN = "moving_down"
    DOOR_OPENING = "door_opening"
    DOOR_OPEN = "door_open"
    DOOR_CLOSING = "door_closing"

Events then cause state transitions:

IDLE
  ↓ request above
MOVING_UP
  ↓ target reached
DOOR_OPENING
  ↓
DOOR_OPEN
  ↓
DOOR_CLOSING
  ↓
MOVING_UP

This becomes particularly useful once you need to handle things such as emergency stops, overloaded lifts, door obstruction, and new requests arriving while the lift is moving.

The interesting part is the optimisation

The basic mechanics of a lift are relatively straightforward.

The difficult part is deciding what optimal means.

A real controller might try to minimise:

total waiting time
+ total travel time
+ number of stops
+ direction changes
+ energy consumption
+ passenger congestion

And these objectives can conflict.

Optimising one passenger's journey may make everybody else's journey worse.

That's why lift scheduling is a useful example of a broader class of software problems: resource allocation under changing constraints.

The same pattern appears in job schedulers, CPU scheduling, delivery routing, database query planning, and many other systems.

You don't necessarily need to find the mathematically perfect solution. A good practical approach is often:

  1. Model the current state.
  2. Generate possible decisions.
  3. Estimate the cost of each decision.
  4. Pick the lowest-cost option.
  5. Recalculate when the state changes.

For a lift, that's enough to go from a simple "nearest lift wins" implementation to a surprisingly sophisticated scheduling system.