Dispatch Strategies
Dispatch is the brain of an elevator system. Each tick, the dispatch strategy looks at which stops have waiting riders and which elevators are idle, then decides where to send each elevator. This chapter covers the four built-in strategies, how to swap between them, and how to write your own.
How dispatch works
During the Dispatch phase of each tick, the simulation:
- Builds a
DispatchManifestcontaining per-stop demand (waiting riders, their weights, their wait times) and per-destination riding riders. - Collects all idle elevators in each group along with their current positions.
- Calls the group’s
DispatchStrategywith this information. - Applies the returned
DispatchDecisionfor each elevator – eitherGoToStop(entity_id)to assign a target, orIdleto do nothing.
Direction indicators (going_up/going_down) are derived automatically from each dispatch decision: GoToStop sets them from target vs. current position, Idle resets them to both-lit. This means SCAN, LOOK, NearestCar, and ETD – along with any custom strategy you write – drive the indicators for free, and downstream boarding gets direction-awareness with no extra work from the strategy. See Direction indicators for details.
Imperative dispatch (destination queue)
If you just want to tell an elevator where to go — no decision-making strategy required — every elevator carries a DestinationQueue (a FIFO of stop EntityIds) that you can push to directly:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
let mut sim: Simulation = todo!();
let elev: EntityId = todo!();
let stop_a: EntityId = todo!();
let stop_b: EntityId = todo!();
sim.push_destination(elev, stop_a).unwrap(); // enqueue at back
sim.push_destination_front(elev, stop_b).unwrap(); // jump ahead of the queue
sim.clear_destinations(elev).unwrap(); // cancel pending work
let queue: &[EntityId] = sim.destination_queue(elev).unwrap();
}
Adjacent duplicates are suppressed: pushing the same stop twice in a row is a no-op (and emits a single DestinationQueued event, not two).
Between the Dispatch and Movement phases, an AdvanceQueue phase reconciles each elevator’s phase/target with the front of its queue. Idle elevators with a non-empty queue begin moving toward the front entry; elevators mid-flight whose queue front has changed (because you called push_destination_front) are redirected. Movement pops the front on arrival.
You can mix the two modes freely: dispatch keeps the queue in sync with its own decisions, so games can observe the queue for visualization and intervene only when needed.
Built-in strategies
| Strategy | Algorithm | Best for | Trade-off |
|---|---|---|---|
ScanDispatch | Sweep end-to-end, reversing at shaft extremes | Single elevator, uniform traffic | Simple and fair, but wastes time traveling past the last request |
LookDispatch | Like SCAN, but reverses at the last request in the current direction | Single elevator, sparse traffic | More efficient than SCAN when requests cluster, slightly less predictable |
NearestCarDispatch | Assign each call to the closest idle elevator | Multi-elevator groups | Low average wait, but can cause bunching when elevators cluster |
EtdDispatch | Minimize estimated time to destination across all riders | Multi-elevator groups with mixed traffic | Best average performance, higher per-tick computation |
Choosing a strategy
Use this rough decision guide:
+-- 1 elevator? ------------------> ScanDispatch (or LookDispatch for bursty demand)
|
Does the group have ... ---+-- 2+ elevators, simple ---------> NearestCarDispatch
|
+-- 2+ elevators, mixed traffic --> EtdDispatch
with SLA-sensitive riders
Concrete guidance:
- ScanDispatch — Start here. Deterministic, fair, easy to reason about. Good baseline for benchmarking custom strategies.
- LookDispatch — Swap in when SCAN wastes obvious time at the extremes (sparse/clustered requests).
- NearestCarDispatch — The default “obvious” multi-car policy. Watch for bunching under heavy load.
- EtdDispatch — Best average wait/ride time in most realistic mixes, at a higher per-tick cost. Use the
delay_weightto favor existing riders vs. new calls.
For everything else (priority, weight, fairness, accessibility) write a custom strategy.
Swapping strategies on the builder
The builder defaults to ScanDispatch. To use a different strategy, call .dispatch():
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
use elevator_core::dispatch::look::LookDispatch;
fn main() -> Result<(), SimError> {
let sim = SimulationBuilder::new()
.stop(StopId(0), "Ground", 0.0)
.stop(StopId(1), "Top", 10.0)
.elevator(ElevatorConfig::default())
.dispatch(LookDispatch::new())
.build()?;
Ok(())
}
All four built-in strategies are available in their respective modules:
#![allow(unused)]
fn main() {
use elevator_core::dispatch::scan::ScanDispatch;
use elevator_core::dispatch::look::LookDispatch;
use elevator_core::dispatch::nearest_car::NearestCarDispatch;
use elevator_core::dispatch::etd::EtdDispatch;
}
The ETD strategy accepts an optional delay weight that controls how much it penalizes delays to existing riders when assigning a new call:
#![allow(unused)]
fn main() {
use elevator_core::dispatch::etd::EtdDispatch;
// Default: delay_weight = 1.0
let etd = EtdDispatch::new();
// Prioritize existing riders more heavily
let etd_conservative = EtdDispatch::with_delay_weight(1.5);
}
Multi-group dispatch
Large buildings often have separate elevator banks – a low-rise group serving floors 1-20 and a high-rise group serving floors 20-40, for example. Each group can have its own dispatch strategy.
Use .dispatch_for_group() on the builder:
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
use elevator_core::dispatch::scan::ScanDispatch;
use elevator_core::dispatch::etd::EtdDispatch;
fn main() -> Result<(), SimError> {
let sim = SimulationBuilder::new()
.stop(StopId(0), "Ground", 0.0)
.stop(StopId(1), "Top", 10.0)
.elevator(ElevatorConfig::default())
.dispatch_for_group(GroupId(0), ScanDispatch::new())
.dispatch_for_group(GroupId(1), EtdDispatch::new())
.build()?;
Ok(())
}
Writing a custom strategy
To implement your own dispatch algorithm, implement the DispatchStrategy trait:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::world::World;
/// Always sends the elevator to the highest stop that has waiting riders.
struct HighestFirstDispatch;
impl DispatchStrategy for HighestFirstDispatch {
fn decide(
&mut self,
elevator: EntityId,
elevator_position: f64,
group: &ElevatorGroup,
manifest: &DispatchManifest,
world: &World,
) -> DispatchDecision {
// Find the highest stop (by position) with waiting riders.
let mut best: Option<(EntityId, f64)> = None;
for &stop_eid in group.stop_entities() {
if manifest.waiting_count_at(stop_eid) == 0 {
continue;
}
if let Some(stop) = world.stop(stop_eid) {
match best {
Some((_, best_pos)) if stop.position() > best_pos => {
best = Some((stop_eid, stop.position()));
}
None => {
best = Some((stop_eid, stop.position()));
}
_ => {}
}
}
}
match best {
Some((stop_eid, _)) => DispatchDecision::GoToStop(stop_eid),
None => DispatchDecision::Idle,
}
}
}
}
Then plug it into the builder:
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
struct HighestFirstDispatch;
impl DispatchStrategy for HighestFirstDispatch {
fn decide(&mut self, _: EntityId, _: f64, _: &elevator_core::dispatch::ElevatorGroup, _: &DispatchManifest, _: &elevator_core::world::World) -> DispatchDecision { DispatchDecision::Idle }
}
fn main() -> Result<(), SimError> {
let sim = SimulationBuilder::new()
.stop(StopId(0), "Ground", 0.0)
.stop(StopId(1), "Top", 10.0)
.elevator(ElevatorConfig::default())
.dispatch(HighestFirstDispatch)
.build()?;
Ok(())
}
The DispatchManifest
Your strategy receives a DispatchManifest with these convenience methods:
| Method | Returns | Description |
|---|---|---|
waiting_count_at(stop) | usize | Number of riders waiting at a stop |
total_weight_at(stop) | f64 | Total weight of riders waiting at a stop |
has_demand(stop) | bool | Whether a stop has any demand (waiting or riding-to) |
riding_count_to(stop) | usize | Number of riders aboard elevators heading to a stop |
For more advanced dispatch (priority-aware, weight-aware, VIP-first), you can iterate manifest.waiting_at_stop directly. Each entry contains a Vec<RiderInfo> with the rider’s id, destination, weight, and wait_ticks.
Opportunistic stops: braking helpers
For strategies that want to consider stopping at a passing floor only if the elevator can brake in time, sim.braking_distance(elev) and sim.future_stop_position(elev) expose the kinematic answer directly — no need to reimplement the trapezoidal physics. The free function elevator_core::movement::braking_distance(velocity, deceleration) is also available for pure computation off a Simulation.
Group-aware dispatch with decide_all
The default DispatchStrategy trait calls decide() once per idle elevator. If your strategy needs to coordinate across all elevators in a group (to avoid sending two elevators to the same stop), override decide_all() instead:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::world::World;
struct MyStrategy;
impl DispatchStrategy for MyStrategy {
fn decide(
&mut self,
_elevator: EntityId,
_pos: f64,
_group: &ElevatorGroup,
_manifest: &DispatchManifest,
_world: &World,
) -> DispatchDecision {
// Required by the trait. When decide_all is overridden, the
// default trait impl calls decide_all instead of this method.
DispatchDecision::Idle
}
fn decide_all(
&mut self,
elevators: &[(EntityId, f64)],
group: &ElevatorGroup,
manifest: &DispatchManifest,
world: &World,
) -> Vec<(EntityId, DispatchDecision)> {
// Your group-level coordination logic here.
elevators
.iter()
.map(|(eid, _)| (*eid, DispatchDecision::Idle))
.collect()
}
}
}
Both NearestCarDispatch and EtdDispatch use this pattern internally to prevent duplicate assignments.
Next steps
Now that you know how dispatch works, head to Extensions and Hooks to learn how to attach custom data to entities and inject logic into the tick loop.