Introduction
elevator-core is an engine-agnostic elevator simulation library written in pure Rust. It gives you a tick-based simulation with realistic trapezoidal motion profiles, pluggable dispatch algorithms, and a typed event bus – everything you need to build elevator games, building management tools, or algorithm testbeds without coupling to any particular game engine or rendering framework.
Who is this for?
- Game developers who want a ready-made elevator simulation they can drop into Bevy, macroquad, or any Rust game engine.
- Algorithm researchers exploring dispatch strategies (SCAN, LOOK, ETD, or your own) on realistic elevator physics.
- Educators looking for a visual, interactive way to teach scheduling and real-time systems concepts.
- Hobbyists who just think elevators are neat.
What can you build?
The library models stops at arbitrary distances along a shaft axis, not uniform floors. That means you can simulate a 5-story office building where each floor is 4 meters apart, a 160-story skyscraper with sky lobbies and express zones, or – why not – a space elevator climbing 1,000 km from a ground station to an orbital platform. The space_elevator.ron config included in the repo does exactly that.
The core crate provides primitives, not opinions. Riders are generic entities that ride elevators. Your game decides whether they are office workers, hotel guests, cargo pallets, or astronauts. You attach semantics through the extension storage system, and the simulation handles the physics and logistics.
What elevator-core is not
- Not a renderer. No graphics, no windowing, no audio. The core crate is headless; see Bevy Integration for a 2D visual wrapper.
- Not real-time. The tick loop runs as fast as you drive it. There is no wall-clock coupling — a tick is whatever
ticks_per_secondsays it is. Games layer real-time scheduling on top. - Not an ECS framework. It uses an ECS-inspired internal layout but exposes a focused simulation API, not a general-purpose ECS.
- Not networked or multi-building. One simulation per process. Federation, multiplayer, and cross-building routing are out of scope.
- Not an optimizer. Built-in dispatch strategies (SCAN, LOOK, NearestCar, ETD) are reference implementations — not tuned for any specific building. Bring your own algorithm if you need optimal performance.
Determinism
Given the same initial config, the same sequence of inputs (spawn_rider, hook mutations, etc.), and a deterministic dispatch strategy, the simulation is fully deterministic. The core loop contains no internal randomness — every tick phase is pure over the world state.
The built-in PoissonSource traffic generator uses a thread-local RNG and is not deterministic across runs. For reproducible traffic, implement a custom TrafficSource over a seeded RNG (e.g., rand::rngs::StdRng::seed_from_u64).
See Snapshots and Determinism for save/load, replay, and seeded traffic patterns.
Stability and MSRV
- MSRV: Rust 1.88. (The crate uses let-chains, which stabilized in 1.88; a CI job pinned to the exact MSRV keeps this honest.)
- Versioning: Semver. Breaking API changes bump the major version. Adding variants to
#[non_exhaustive]enums (events, errors) is not considered breaking. - Release cadence: Managed via release-please; see
CHANGELOG.mdin the repo.
Project structure
The repository is a Cargo workspace with two crates:
| Crate | Purpose |
|---|---|
elevator-core | The simulation library. Pure Rust, no engine dependencies. This is what you add to your project. |
elevator-bevy | A Bevy 0.18 binary that wraps the core sim with 2D rendering, a HUD, and keyboard controls. Useful as a reference implementation and visual debugger. |
Links
Next steps
Head to Getting Started to build your first simulation in under 30 lines of Rust.
Getting Started
In this chapter we will build a minimal elevator simulation from scratch: a 3-stop building with one elevator, a single rider, and a loop that runs until the rider arrives at their destination.
Add the dependency
cargo add elevator-core
Import the prelude
The prelude re-exports everything you need for typical usage:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
}
This brings in, at a glance:
| Group | Items |
|---|---|
| Builder & sim | SimulationBuilder, Simulation, RiderBuilder |
| Components | Rider, RiderPhase, Elevator, ElevatorPhase, Stop, Line, Position, Velocity, FloorPosition, Route, Patience, Preferences, AccessControl, Orientation, ServiceMode |
| Config | SimConfig, GroupConfig, LineConfig |
| Dispatch traits | DispatchStrategy, RepositionStrategy |
| Reposition strategies | NearestIdle, ReturnToLobby, SpreadEvenly, DemandWeighted |
| Identity | EntityId, StopId, GroupId |
| Errors & events | SimError, RejectionReason, RejectionContext, Event, EventBus |
| Misc | Metrics, TimeAdapter |
Not in the prelude (import explicitly): the concrete built-in dispatch types (ScanDispatch, LookDispatch, NearestCarDispatch, EtdDispatch — see Dispatch Strategies), ElevatorConfig and StopConfig from elevator_core::config, the traffic module (feature-gated), the snapshot module, and the World type (needed as a parameter when implementing custom dispatch).
Feature flags
| Flag | Default? | Enables |
|---|---|---|
traffic | yes | traffic module: PoissonSource, TrafficPattern, TrafficSchedule. Pulls in rand. |
energy | no | Per-elevator EnergyProfile/EnergyMetrics components and snapshot fields. |
Turn off defaults with default-features = false if you want a leaner build and intend to write your own rider spawning.
Build a simulation
We will use SimulationBuilder to set up a 3-stop building with one elevator, SCAN dispatch (the builder’s default), and 60 ticks per second. ElevatorConfig implements Default with sensible physics (max speed 2.0, acceleration 1.5, deceleration 2.0, 800 kg capacity) so the elevator is one line; stops we spell out because positions are the whole point.
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
fn main() -> Result<(), SimError> {
let mut sim = SimulationBuilder::new()
.stop(StopId(0), "Lobby", 0.0)
.stop(StopId(1), "Floor 2", 4.0)
.stop(StopId(2), "Floor 3", 8.0)
.elevator(ElevatorConfig { starting_stop: StopId(0), ..Default::default() })
.building_name("Tutorial Tower")
.build()?;
Ok(())
}
Override any ElevatorConfig field with struct-update syntax (ElevatorConfig { max_speed: 4.0, ..Default::default() }) — the Configuration chapter covers every field.
Spawn a rider
A rider is anything that rides an elevator. To spawn one, you provide an origin stop, a destination stop, and a weight:
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
fn main() -> Result<(), SimError> {
let mut sim = SimulationBuilder::new()
.stop(StopId(0), "Lobby", 0.0)
.stop(StopId(1), "Floor 2", 4.0)
.stop(StopId(2), "Floor 3", 8.0)
.elevator(ElevatorConfig { starting_stop: StopId(0), ..Default::default() })
.build()?;
let rider_id = sim.spawn_rider_by_stop_id(
StopId(0), // origin: Lobby
StopId(2), // destination: Floor 3
75.0, // weight in kg
)?;
println!("Spawned rider: {:?}", rider_id);
Ok(())
}
spawn_rider_by_stop_id maps config-level StopId values to runtime EntityId values internally. It returns Result<EntityId, SimError> – it will fail if you pass a StopId that does not exist in your building.
Run the simulation loop
Each call to sim.step() advances the simulation by one tick, running all eight phases of the tick loop (advance transient, dispatch, reposition, advance queue, movement, doors, loading, metrics). After stepping, you can drain events to see what happened:
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
fn main() -> Result<(), SimError> {
let mut sim = SimulationBuilder::new()
.stop(StopId(0), "Lobby", 0.0)
.stop(StopId(1), "Floor 2", 4.0)
.stop(StopId(2), "Floor 3", 8.0)
.elevator(ElevatorConfig { starting_stop: StopId(0), ..Default::default() })
.build()?;
let rider_id = sim.spawn_rider_by_stop_id(StopId(0), StopId(2), 75.0)?;
let mut arrived = false;
while !arrived {
sim.step();
for event in sim.drain_events() {
match event {
Event::RiderBoarded { rider, elevator, tick } => {
println!("Tick {}: rider {:?} boarded elevator {:?}", tick, rider, elevator);
}
Event::ElevatorArrived { elevator, at_stop, tick } => {
println!("Tick {}: elevator {:?} arrived at stop {:?}", tick, elevator, at_stop);
}
Event::RiderExited { rider, stop, tick, .. } => {
println!("Tick {}: rider {:?} arrived at stop {:?}", tick, rider, stop);
if rider == rider_id {
arrived = true;
}
}
_ => {}
}
}
}
println!("Rider delivered!");
println!("Total ticks: {}", sim.current_tick());
println!("Avg wait time: {:.1} ticks", sim.metrics().avg_wait_time());
Ok(())
}
The complete program
Here is everything together as a single runnable file:
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
fn main() -> Result<(), SimError> {
// 1. Build a 3-stop building.
let mut sim = SimulationBuilder::new()
.stop(StopId(0), "Lobby", 0.0)
.stop(StopId(1), "Floor 2", 4.0)
.stop(StopId(2), "Floor 3", 8.0)
.elevator(ElevatorConfig { starting_stop: StopId(0), ..Default::default() })
.building_name("Tutorial Tower")
.build()?;
// 2. Spawn a rider going from the Lobby to Floor 3.
let rider_id = sim.spawn_rider_by_stop_id(StopId(0), StopId(2), 75.0)?;
// 3. Run until the rider arrives.
let mut arrived = false;
while !arrived {
sim.step();
for event in sim.drain_events() {
match event {
Event::RiderBoarded { rider, elevator, tick } => {
println!("Tick {tick}: rider {rider:?} boarded elevator {elevator:?}");
}
Event::ElevatorArrived { elevator, at_stop, tick } => {
println!("Tick {tick}: elevator {elevator:?} arrived at {at_stop:?}");
}
Event::RiderExited { rider, stop, tick, .. } => {
println!("Tick {tick}: rider {rider:?} exited at {stop:?}");
if rider == rider_id {
arrived = true;
}
}
_ => {}
}
}
}
// 4. Print summary metrics.
println!("\n--- Summary ---");
println!("Total ticks: {}", sim.current_tick());
// Metrics implements Display for a compact one-liner.
println!("{}", sim.metrics());
Ok(())
}
Run it with cargo run and you should see the rider move from the Lobby to Floor 3, with events printed along the way. The summary output looks like:
--- Summary ---
Total ticks: 482
1 delivered, avg wait 87.3t, 0% util
What just happened?
- The builder created a
Simulationcontaining aWorldwith three stop entities and one elevator entity, plus a SCAN dispatch strategy. spawn_rider_by_stop_idcreated a rider entity at the Lobby with a route to Floor 3.- Each
step()ran the seven-phase tick loop. The dispatch phase noticed a waiting rider and sent the elevator to the Lobby. The reposition phase was a no-op (no reposition strategy configured). The movement phase moved the elevator using a trapezoidal velocity profile. The doors phase opened and closed doors. The loading phase boarded and exited the rider. The metrics phase updated aggregate stats. - Events fired at each significant moment, and we pattern-matched on them to detect arrival.
Next up: Core Concepts dives deeper into the entity model, the tick loop phases, and the lifecycle of riders and elevators.
Core Concepts
This chapter covers the mental model behind elevator-core: how entities and components fit together, what happens during each tick, and how riders and elevators move through their lifecycles.
The World
At the center of the simulation is the World – a struct-of-arrays entity store inspired by ECS architecture. Every meaningful thing in the simulation (stops, elevators, riders) is an entity, identified by an EntityId. Entities have components attached to them – typed data like Position, Elevator, Rider, or Stop.
World
+-- Entity 0 (Stop) -> Stop { name: "Lobby", position: 0.0 }, Position { value: 0.0 }
+-- Entity 1 (Stop) -> Stop { name: "Floor 2", position: 4.0 }, Position { value: 4.0 }
+-- Entity 2 (Elevator) -> Elevator { phase: Idle, ... }, Position { value: 0.0 }, Velocity { value: 0.0 }
+-- Entity 3 (Rider) -> Rider { phase: Waiting, weight: 75.0, ... }, Route { ... }
You access the world through sim.world() (shared) and sim.world_mut() (mutable). The simulation also provides convenience methods like sim.spawn_rider_by_stop_id() that handle world operations for you.
Identity types
The library uses several identity types, and it is important to understand which one to use where:
| Type | What it identifies | When you use it |
|---|---|---|
EntityId | Any entity at runtime (stop, elevator, rider) | Event payloads, world lookups, dispatch decisions |
StopId | A stop in the config (e.g., StopId(0)) | Builder API, config files, spawn_rider_by_stop_id |
GroupId | An elevator group (e.g., GroupId(0)) | Multi-group dispatch, group-specific hooks |
StopId is a config-level concept. When the simulation boots, each StopId is mapped to an EntityId. At runtime you work with EntityId everywhere – events, world queries, dispatch. Use sim.stop_entity(StopId(0)) if you need to convert.
Topology: groups, lines, elevators, stops
Multi-bank buildings are modeled with a three-level hierarchy:
Group (GroupId)
+-- Line (LineConfig) <-- a physical shaft or column of stops
| +-- Elevator <-- one car running on this line
| +-- Elevator
+-- Line
+-- Elevator
- A Group owns a dispatch strategy and a set of stops it serves. Typical use: “low-rise group” and “high-rise group” in a tall building.
- A Line represents a shaft (or columnar group of shafts sharing the same physical path). Elevators are assigned to a line; they only serve stops their line reaches.
- A Stop may be shared across lines (e.g., a sky lobby served by both low-rise and high-rise groups).
- An Elevator belongs to exactly one line within one group at a time. Use
ElevatorReassigned/LineReassignedevents to observe runtime moves.
The simplest buildings (single bank, single shaft) can ignore lines — the builder auto-creates one default line and one default group, and you can just call .stop(...) / .elevator(...) without touching LineConfig or GroupConfig.
Coordinate system and units
- Axis. All positions are scalars along a single shaft axis. Higher values = higher up (or further along the axis for horizontal configs like the space elevator). There is no 2D/3D geometry in the core.
- Units are unspecified. The library does not enforce meters, feet, or any other unit — positions, velocities, accelerations, and weights are just
f64values. Internally consistent is all that matters. Convention: meters + kg + ticks. - Origin. There is no privileged zero. Stop 0 does not have to be at position
0.0. Positions may be negative (useful for basements below a lobby at0.0, or for space elevators anchored at a non-zero reference frame). - Time. The fundamental unit is the tick. Convert to seconds via
sim.time().ticks_to_seconds(t)(usesticks_per_secondfrom config). The default is 60 ticks/second.
The tick loop
Each call to sim.step() runs one simulation tick. A tick consists of eight phases, always executed in this order:
+------------+ +------------+ +--------------+ +--------------+
| Advance |-->| Dispatch |-->| Reposition |-->| Advance |
| Transient | | | | | | Queue |
+------------+ +------------+ +--------------+ +--------------+
|
+------------+ +------------+ +------------+ +------------+
| Metrics |<--| Loading |<--| Doors |<--| Movement |
| | | | | | | |
+------------+ +------------+ +------------+ +------------+
Phase 1: Advance Transient
Riders in transitional states are advanced to their next phase:
Boarding->Riding(the rider is now inside the elevator)Exiting->Arrived(the rider has left the elevator and is done)
This ensures that boarding and exiting – which are set during the Loading phase – take effect at the start of the next tick, giving events a clean boundary.
Phase 2: Dispatch
The dispatch strategy examines all idle elevators and waiting riders, then decides where each elevator should go. The strategy receives a DispatchManifest with full demand information (who is waiting where, who is riding to where) and returns a DispatchDecision for each elevator.
The default strategy is SCAN (sweep end-to-end). You can swap in LOOK, NearestCar, ETD, or your own custom strategy – see Dispatch Strategies.
Phase 3: Reposition
Optional phase; idle elevators are repositioned for better coverage via the RepositionStrategy. Only runs if at least one group has a strategy configured.
Phase 4: Advance Queue
Reconciles each elevator’s current phase/target with the front of its DestinationQueue. This is where imperative pushes from game code (sim.push_destination, sim.push_destination_front) take effect: an idle elevator with a non-empty queue transitions to MovingToStop(front), and an elevator already in transit is redirected if a push_front changed the queue head. Zero-impact for games that never touch the queue — dispatch keeps the queue and target_stop in sync on its own.
Phase 5: Movement
Elevators with a target stop are moved along the shaft axis using a trapezoidal velocity profile: accelerate up to max speed, cruise, then decelerate to stop precisely at the target position. This produces realistic motion without requiring complex physics.
When an elevator arrives at its target stop, it emits an ElevatorArrived event and transitions to the door-opening state.
Phase 6: Doors
The door finite-state machine ticks for each elevator. Doors transition through:
Closed -> Opening (transition ticks) -> Open (hold ticks) -> Closing (transition ticks) -> Closed
DoorOpened and DoorClosed events fire at the appropriate moments. Riders can only board or exit when the doors are fully open.
Phase 7: Loading
While an elevator’s doors are open at a stop:
- Exiting: riders whose destination matches the current stop exit the elevator.
- Boarding: waiting riders at the current stop enter the elevator, subject to weight capacity.
Riders that exceed the elevator’s remaining capacity are rejected with a RiderRejected event.
Phase 8: Metrics
Events from the current tick are processed to update aggregate metrics – average wait time, ride time, throughput, abandonment rate, and total distance. Tagged metrics (per-zone or per-label breakdowns) are also updated here.
Rider lifecycle
A rider moves through these phases:
Waiting --> Boarding --> Riding --> Exiting --> Arrived
^ |
| settle_rider() --> Resident
| |
+------------- reroute_rider() ----------------+
Waiting ----> Abandoned (patience expired)
|
+--> settle_rider() --> Resident
| Phase | Where is the rider? | What triggers the transition? |
|---|---|---|
Waiting | At a stop, in the queue | Elevator arrives, doors open, loading phase boards them |
Boarding | Being loaded into the elevator | Advance Transient phase (next tick) |
Riding | Inside the elevator | Elevator arrives at destination, doors open, loading phase exits them |
Exiting | Exiting the elevator | Advance Transient phase (next tick) |
Arrived | Reached final destination | Consumer decides: settle (-> Resident), despawn, or leave |
Abandoned | Left the stop | Patience ran out; consumer can settle or despawn |
Resident | Parked at a stop, not seeking an elevator | Consumer calls settle_rider() on an Arrived or Abandoned rider |
Each transition emits an event: RiderSpawned, RiderBoarded, RiderExited, RiderAbandoned, RiderSettled, RiderRerouted, RiderDespawned.
Population tracking
Riders at each stop are tracked by a reverse index, enabling O(1) queries without scanning the full entity list.
Three query methods provide population lookups:
sim.residents_at(stop)– riders settled at a stopsim.waiting_at(stop)– riders waiting for an elevator at a stopsim.abandoned_at(stop)– riders who gave up waiting at a stop
Each method has a corresponding count variant (e.g., sim.residents_at(stop).len()).
Entity type checks
To identify what an EntityId refers to, use the type-check helpers:
sim.is_elevator(id)— the entity has anElevatorcomponentsim.is_rider(id)— the entity has aRidercomponentsim.is_stop(id)— the entity has aStopcomponent
These are preferable to querying world.elevator(id).is_some() etc., and make game code more readable.
Three lifecycle methods manage rider state transitions:
sim.settle_rider(id)– transitions an Arrived or Abandoned rider to Residentsim.reroute_rider(id, route)– sends a Resident rider back to Waiting with a new routesim.despawn_rider(id)– removes the rider and updates all indexes
Use sim.despawn_rider(id) instead of calling world.despawn() directly – it keeps the stop index consistent.
Elevator lifecycle
Elevators cycle through these phases:
| Phase | Meaning |
|---|---|
Idle | No target, waiting for dispatch to assign a stop |
MovingToStop(EntityId) | Traveling toward a target stop |
DoorOpening | Doors are currently opening |
Loading | Doors open; riders may board or exit |
DoorClosing | Doors are currently closing |
Stopped | At a floor, doors closed, awaiting dispatch |
An elevator arriving at a stop cycles through DoorOpening → Loading → DoorClosing → Stopped. If dispatch assigns a new target, the elevator departs from Stopped.
Direction indicators
Every elevator carries two indicator lamps: going_up and going_down. Together they tell riders (and the loading system) which direction the car will serve next.
going_up | going_down | Meaning |
|---|---|---|
true | true | Idle — the car will accept riders in either direction |
true | false | Committed to an upward trip |
false | true | Committed to a downward trip |
The lamps are auto-managed by the dispatch phase:
- On
DispatchDecision::GoToStop(target), the car’s indicators are set fromtargetvs. current position. - On
DispatchDecision::Idlethe pair resets to(true, true). - A
DirectionIndicatorChangedevent is emitted only when the pair actually changes.
The loading phase uses these lamps as a filter: a rider whose next route leg heads up (dest_pos > cur_pos) won’t board a car with going_up = false, and vice versa. The rider is silently left waiting — no rejection event is emitted — so a later car heading in their direction picks them up naturally. Idle cars (both lamps lit) accept riders in either direction.
Read the lamps through sim.elevator_going_up(id) / sim.elevator_going_down(id), or directly off the component via Elevator::going_up() / going_down().
Sub-stepping
For advanced use cases, you can run individual phases instead of calling step():
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
fn main() -> Result<(), SimError> {
let mut sim = SimulationBuilder::new()
.stop(StopId(0), "Ground", 0.0)
.stop(StopId(1), "Top", 10.0)
.elevator(ElevatorConfig::default())
.build()?;
sim.run_advance_transient();
sim.run_dispatch();
sim.run_reposition();
sim.run_movement();
sim.run_doors();
sim.run_loading();
sim.run_metrics();
sim.advance_tick(); // flush events and increment tick counter
Ok(())
}
This is equivalent to sim.step() but lets you inject logic between phases or skip phases entirely. Lifecycle hooks (covered in Extensions and Hooks) provide a less manual way to achieve this.
Next steps
Now that you understand the architecture, head to Dispatch Strategies to learn how elevators decide where to go.
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.
Writing a Custom Dispatch Strategy
The built-in strategies (SCAN, LOOK, NearestCar, ETD) cover most general-purpose needs. Write a custom strategy when you need domain-specific behavior the built-ins don’t capture — priority lanes, VIP handling, freight vs. passenger separation, fairness guarantees, energy-aware dispatch.
This chapter is a narrative tutorial that walks from a minimal strategy to a production-grade one with snapshot support. If you just need the API surface, the Dispatch Strategies chapter has the reference sketch.
The trait surface
pub trait DispatchStrategy: Send + Sync {
/// Decide where one idle elevator should go.
fn decide(
&mut self,
elevator: EntityId,
elevator_position: f64,
group: &ElevatorGroup,
manifest: &DispatchManifest,
world: &World,
) -> DispatchDecision;
/// Decide for all idle elevators in one pass (optional).
///
/// Default implementation calls `decide` once per elevator.
/// Override when the strategy must coordinate across elevators —
/// for example, to prevent two cars from being sent to the same
/// hall call.
fn decide_all(
&mut self,
elevators: &[(EntityId, f64)],
group: &ElevatorGroup,
manifest: &DispatchManifest,
world: &World,
) -> Vec<(EntityId, DispatchDecision)> { /* default: per-elevator */ }
/// Clean up per-elevator state when an elevator is removed.
///
/// Strategies with internal `HashMap<EntityId, _>` state must
/// remove the entry here — otherwise the map grows unbounded
/// and cross-group reassignments leave stale entries.
fn notify_removed(&mut self, _elevator: EntityId) { /* default: no-op */ }
}
Three methods, three clear responsibilities. Everything else you need comes from DispatchManifest and ElevatorGroup.
Step 1 — The simplest possible strategy
“Always send idle elevators to the stop with the most waiting riders.”
use elevator_core::dispatch::{
DispatchDecision, DispatchManifest, DispatchStrategy, ElevatorGroup,
};
use elevator_core::entity::EntityId;
use elevator_core::world::World;
struct BusiestStopFirst;
impl DispatchStrategy for BusiestStopFirst {
fn decide(
&mut self,
_elevator: EntityId,
_position: f64,
group: &ElevatorGroup,
manifest: &DispatchManifest,
_world: &World,
) -> DispatchDecision {
group
.stop_entities()
.iter()
.filter(|&&s| manifest.has_demand(s))
.max_by_key(|&&s| manifest.waiting_count_at(s))
.copied()
.map_or(DispatchDecision::Idle, DispatchDecision::GoToStop)
}
}
What this gets you:
- The simulation drives direction indicators automatically based on
GoToStopvs.Idle. DestinationQueuemanagement happens in theAdvanceQueuephase — you don’t touch it.- The dispatch phase events (
ElevatorAssigned,ElevatorIdle,DirectionIndicatorChanged) emit automatically.
What this strategy doesn’t handle: two idle elevators will both be sent to the same stop. For that, you need decide_all.
Step 2 — Coordinating across elevators with decide_all
The problem: decide runs independently per elevator. If stops A and B both have demand and elevators E1 and E2 are both idle, calling decide twice may return GoToStop(A) both times — one elevator goes unused.
Override decide_all to pair elevators with stops exactly once:
impl DispatchStrategy for BusiestStopFirst {
fn decide(
&mut self,
_elevator: EntityId,
_position: f64,
_group: &ElevatorGroup,
_manifest: &DispatchManifest,
_world: &World,
) -> DispatchDecision {
// Required by the trait. When `decide_all` is overridden, this
// is unreachable on the dispatch path.
DispatchDecision::Idle
}
fn decide_all(
&mut self,
elevators: &[(EntityId, f64)],
group: &ElevatorGroup,
manifest: &DispatchManifest,
_world: &World,
) -> Vec<(EntityId, DispatchDecision)> {
let mut stops: Vec<EntityId> = group
.stop_entities()
.iter()
.copied()
.filter(|&s| manifest.has_demand(s))
.collect();
stops.sort_by_key(|&s| std::cmp::Reverse(manifest.waiting_count_at(s)));
let mut results = Vec::with_capacity(elevators.len());
let mut stops_iter = stops.into_iter();
for &(eid, _) in elevators {
match stops_iter.next() {
Some(stop) => results.push((eid, DispatchDecision::GoToStop(stop))),
None => results.push((eid, DispatchDecision::Idle)),
}
}
results
}
}
NearestCarDispatch and EtdDispatch both use this pattern internally.
Step 3 — Carrying state, and the notify_removed contract
If your strategy tracks something per elevator (direction history, last-served stop, priority bookkeeping), it owns a HashMap<EntityId, _>. That map must be cleaned up when an elevator is removed or reassigned across groups, or it grows forever.
The framework calls notify_removed(elevator) on the group’s dispatcher whenever:
Simulation::remove_elevator(id)is called, ORSimulation::reassign_elevator_to_line(id, new_line)moves an elevator across groups (same-group moves don’t firenotify_removedbecause the dispatcher still owns the elevator).
Forgetting to implement this is the most common correctness bug in custom strategies. ScanDispatch and LookDispatch both use it to evict direction entries.
use std::collections::HashMap;
#[derive(Default)]
struct PriorityDispatch {
/// Per-elevator cooldown — once this elevator served a priority stop,
/// suppress priority preference for N ticks so non-priority riders
/// aren't starved.
cooldown_ticks: HashMap<EntityId, u64>,
}
impl DispatchStrategy for PriorityDispatch {
fn decide(/* ... */) -> DispatchDecision { /* ... */ }
fn notify_removed(&mut self, elevator: EntityId) {
// CRITICAL: keeps the map from growing unbounded under churn.
self.cooldown_ticks.remove(&elevator);
}
}
Step 4 — Snapshot support
Simulations can be serialized via Simulation::snapshot() for save/load, replay, and deterministic testing. The snapshot records each group’s dispatch strategy by name. Built-in strategies serialize to specific variants (BuiltinStrategy::Scan, ::Look, ::NearestCar, ::Etd); custom strategies serialize to BuiltinStrategy::Custom(String).
On restore, WorldSnapshot::restore() takes an optional factory function that maps the custom name back to a strategy instance. If you don’t provide one, custom strategies silently become ScanDispatch on restore — your save/load round trip will be wrong.
The canonical pattern:
use elevator_core::dispatch::{BuiltinStrategy, DispatchStrategy};
use elevator_core::ids::GroupId;
use elevator_core::snapshot::WorldSnapshot;
const PRIORITY_NAME: &str = "priority";
// When building the sim, install the strategy via `Simulation::set_dispatch`,
// which takes both the strategy and the `BuiltinStrategy` id used for
// snapshot serialization. The builder's `.dispatch(...)` helper installs
// the strategy but defaults the id to `BuiltinStrategy::Scan` — fine for
// the built-in strategies, wrong for custom ones.
let mut sim = SimulationBuilder::new()
.stop(StopId(0), "Ground", 0.0)
.stop(StopId(1), "Top", 10.0)
.elevator(ElevatorConfig::default())
.build()?;
sim.set_dispatch(
GroupId(0),
Box::new(PriorityDispatch::default()),
BuiltinStrategy::Custom(PRIORITY_NAME.into()),
);
// When restoring:
let snapshot: WorldSnapshot = /* deserialized from RON/JSON/bincode */;
let sim = snapshot.restore(Some(&|name: &str| -> Option<Box<dyn DispatchStrategy>> {
match name {
PRIORITY_NAME => Some(Box::new(PriorityDispatch::default())),
// Return `None` for unknown names — the restore falls back to
// `ScanDispatch` rather than panicking.
_ => None,
}
}));
The name is opaque to the library. Keep it stable across releases — changing the name breaks old saved snapshots.
Step 5 — Testing a custom strategy
Two levels of test coverage:
Unit-test decide in isolation. Construct a minimal World, an ElevatorGroup, and a DispatchManifest, then call your strategy’s decide directly. This is how the built-in strategies are tested; see crates/elevator-core/src/tests/dispatch_tests.rs for the helper pattern (test_world(), test_group(), spawn_elevator(), add_demand()).
#[test]
fn busiest_stop_wins() {
let (mut world, stops) = test_world(); // 4 stops at 0/4/8/12
let elev = spawn_elevator(&mut world, 0.0);
let group = test_group(&stops, vec![elev]);
let mut manifest = DispatchManifest::default();
add_demand(&mut manifest, &mut world, stops[1], 70.0);
add_demand(&mut manifest, &mut world, stops[2], 70.0);
add_demand(&mut manifest, &mut world, stops[2], 70.0); // 2 riders at stops[2]
let mut strategy = BusiestStopFirst;
let decision = strategy.decide(elev, 0.0, &group, &manifest, &world);
assert_eq!(decision, DispatchDecision::GoToStop(stops[2]));
}
Integration-test via a full Simulation. Spawn riders, step the loop, assert on events (ElevatorAssigned, RiderBoarded, etc.). This catches bugs that only surface through the 8-phase interaction — e.g., a strategy that pushes duplicate targets, or one that produces decisions that the AdvanceQueue phase later undoes.
Performance considerations
decide/decide_allrun once per tick per group. At 60 ticks/second and a realistic group size (20 elevators, 50 stops), that’s tens of thousands of calls per simulated minute. Keep the hot path allocation-free where possible.SmallVec<[T; N]>is already the storage choice in the built-in strategies for the “ahead” / “behind” partitions. If you partition elevators or stops, consider the same.- The
DispatchManifestis immutable — never try to mutate demand from insidedecide. If you need to track per-rider state across ticks, store it in your strategy. - Avoid
HashMap<EntityId, _>iteration in the hot path — it’s nondeterministic. UseBTreeMapor sort the keys.
Putting it together: a runnable example
See examples/custom_dispatch.rs in the repository — a complete file implementing a round-robin strategy with all three trait methods, ready to cargo run --example custom_dispatch.
Next steps
- Extensions and Hooks — attach per-rider / per-elevator data (VIP tags, priority, preferences) that your strategy can consult via
world.get_ext::<T>(id). - Snapshots and Determinism — full snapshot/restore cycle, with emphasis on the custom-strategy factory.
- Metrics and Events — what dispatch emits and how to consume it for debugging.
Extensions and Hooks
The core library is deliberately unopinionated – it provides riders, elevators, and stops, but your game decides what a rider means. Maybe riders have a VIP status, a mood, a destination preference, or a cargo manifest. Extensions and hooks are how you layer game-specific logic on top of the simulation without forking or wrapping.
Extension components
Extension components let you attach arbitrary typed data to any entity. They work like the built-in components (Rider, Elevator, etc.) but are defined by your code.
Step 1: Define your type
Extension types must implement Serialize and DeserializeOwned (for snapshot support), plus Send + Sync:
#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct VipTag {
level: u32,
lounge_access: bool,
}
}
Step 2: Register with the builder
Call .with_ext::<T>("name") on the builder to register the extension type. The name string is used for snapshot serialization:
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct VipTag { level: u32, lounge_access: bool }
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
fn main() -> Result<(), SimError> {
let mut sim = SimulationBuilder::new()
.stop(StopId(0), "Ground", 0.0)
.stop(StopId(1), "Top", 10.0)
.elevator(ElevatorConfig::default())
.with_ext::<VipTag>("vip_tag")
.build()?;
Ok(())
}
Step 3: Attach to entities
Use world.insert_ext() to attach your component to an entity:
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct VipTag { level: u32, lounge_access: bool }
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
fn main() -> Result<(), SimError> {
let mut sim = SimulationBuilder::new()
.stop(StopId(0), "Ground", 0.0)
.stop(StopId(1), "Top", 10.0)
.elevator(ElevatorConfig::default())
.with_ext::<VipTag>("vip_tag")
.build()?;
let rider_id = sim.spawn_rider_by_stop_id(StopId(0), StopId(1), 75.0)?;
sim.world_mut().insert_ext(
rider_id,
VipTag { level: 3, lounge_access: true },
"vip_tag",
);
Ok(())
}
Step 4: Read it back
Use world.get_ext() for a cloned value, or world.get_ext_mut() for a mutable reference:
#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct VipTag { level: u32, lounge_access: bool }
use elevator_core::prelude::*;
fn run(sim: &mut Simulation, rider_id: EntityId) {
// Read (cloned)
if let Some(vip) = sim.world().get_ext::<VipTag>(rider_id) {
println!("VIP level: {}", vip.level);
}
// Mutate
if let Some(vip) = sim.world_mut().get_ext_mut::<VipTag>(rider_id) {
vip.level += 1;
}
}
}
Extensions are automatically cleaned up when an entity is despawned, and they participate in snapshot save/load as long as you register them with .with_ext() or world.register_ext().
World resources
For global data that is not attached to a specific entity, use resources. Resources are typed singletons stored on the World:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
fn run(sim: &mut Simulation) {
// Insert a resource
sim.world_mut().insert_resource(42u32);
// Read it
if let Some(value) = sim.world().resource::<u32>() {
println!("The answer is {}", value);
}
// Mutate it
if let Some(value) = sim.world_mut().resource_mut::<u32>() {
*value += 1;
}
}
}
Resources are useful for game state that hooks need to read or write – score counters, time-of-day multipliers, spawn rate controllers, and so on.
Extension vs. resource: which do I want?
| Need | Use |
|---|---|
| Per-entity data that varies by rider/elevator (VIP status, mood, cargo manifest) | Extension (with_ext + insert_ext) |
| One-of value for the whole sim (score, difficulty, tick clock mirror) | Resource (insert_resource) |
| Data that must survive snapshot save/load | Extension (register by name; resources are not snapshotted) |
| Quick scratchpad you can wipe between ticks | Resource |
| Query “all entities that have X” | Extension (iterate world.rider_ids() and filter on get_ext::<T>) |
Extensions are auto-cleaned on despawn_rider; resources persist until you remove them.
Lifecycle hooks
Hooks let you inject custom logic before or after any of the seven tick phases. They receive &mut World, so they can read and modify any entity or resource.
Registering hooks on the builder
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
fn main() -> Result<(), SimError> {
let sim = SimulationBuilder::new()
.stop(StopId(0), "Ground", 0.0)
.stop(StopId(1), "Top", 10.0)
.elevator(ElevatorConfig::default())
.after(Phase::Loading, |world| {
// This runs after every Loading phase.
// Check for newly arrived riders, update scores, etc.
})
.before(Phase::Dispatch, |world| {
// This runs before every Dispatch phase.
// Adjust demand, spawn dynamic riders, etc.
})
.build()?;
Ok(())
}
Registering hooks after build
You can also add hooks to a running simulation:
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
fn main() -> Result<(), SimError> {
let mut sim = SimulationBuilder::new()
.stop(StopId(0), "Ground", 0.0)
.stop(StopId(1), "Top", 10.0)
.elevator(ElevatorConfig::default())
.build()?;
sim.add_after_hook(Phase::Loading, |world| {
// Post-loading logic
});
Ok(())
}
Group-specific hooks
For multi-group buildings, you can register hooks that only fire for a specific elevator group:
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
fn main() -> Result<(), SimError> {
let sim = SimulationBuilder::new()
.stop(StopId(0), "Ground", 0.0)
.stop(StopId(1), "Top", 10.0)
.elevator(ElevatorConfig::default())
.after_group(Phase::Loading, GroupId(0), |world| {
// Only runs after loading for group 0
})
.build()?;
Ok(())
}
What hooks can (and can’t) do
Hooks receive &mut World — not &mut Simulation. This means:
- You can: read/mutate any component, insert/remove extensions, add/remove resources, read tick state via a resource mirror (see example below).
- You cannot: call
sim.step(),sim.spawn_rider_by_stop_id(),sim.snapshot()or otherSimulation-level methods while a tick is in flight — the simulation is borrowed. - Spawning during a hook: use
world.spawn()+ direct component inserts, or queueSpawnRequests into a resource and drain them aftersim.step()returns. Thebefore(Phase::Dispatch, ...)slot is a convenient place to inject riders because the next phase will see them. - Events: hooks do not emit events directly. Use an extension/resource to record side effects and translate to events in game code.
Hook execution order within a tick:
before(AdvanceTransient) -> [phase] -> after(AdvanceTransient)
before(Dispatch) -> [phase] -> after(Dispatch)
... and so on for each phase
Group-specific hooks run alongside global hooks for the same phase; all before hooks fire before the phase body, all after hooks fire after.
Available phases
| Phase | When hooks run |
|---|---|
Phase::AdvanceTransient | Before/after transitional states advance |
Phase::Dispatch | Before/after elevator assignment |
Phase::Reposition | Before/after idle-elevator repositioning (no-op if no reposition strategy configured) |
Phase::Movement | Before/after position updates |
Phase::Doors | Before/after door state machine ticks |
Phase::Loading | Before/after boarding and exiting |
Phase::Metrics | Before/after metric aggregation |
Combining extensions and hooks
The real power comes from using extensions and hooks together. Here is a walkthrough: we will track how long each rider has been waiting and print a warning if they wait too long.
The hook closure cannot call sim.current_tick() directly (the simulation is borrowed during the tick), so we store the tick in a World resource and update it each iteration. The CurrentTick helper struct is defined at the bottom of the listing.
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct WaitWarning {
warned: bool,
}
fn main() -> Result<(), SimError> {
let mut sim = SimulationBuilder::new()
.stop(StopId(0), "Lobby", 0.0)
.stop(StopId(1), "Floor 2", 4.0)
.stop(StopId(2), "Floor 3", 8.0)
.elevator(ElevatorConfig { starting_stop: StopId(0), ..Default::default() })
.with_ext::<WaitWarning>("wait_warning")
.after(Phase::Metrics, |world| {
// Check all waiting riders for long waits.
let rider_ids: Vec<EntityId> = world.rider_ids();
for rid in rider_ids {
let Some(rider) = world.rider(rid) else { continue };
if rider.phase() != RiderPhase::Waiting { continue; }
let current_tick = world.resource::<CurrentTick>()
.map_or(0, |t| t.0);
let wait = current_tick.saturating_sub(rider.spawn_tick());
if wait > 300 {
if let Some(warning) = world.get_ext_mut::<WaitWarning>(rid) {
if !warning.warned {
warning.warned = true;
println!("WARNING: rider {:?} has been waiting {} ticks!", rid, wait);
}
}
}
}
})
.build()?;
// Seed the resource the hook reads.
sim.world_mut().insert_resource(CurrentTick(0));
// Spawn some riders and attach extensions.
let r1 = sim.spawn_rider_by_stop_id(StopId(0), StopId(2), 75.0)?;
sim.world_mut().insert_ext(r1, WaitWarning { warned: false }, "wait_warning");
for _ in 0..600 {
// Update the current tick resource before stepping.
if let Some(t) = sim.world_mut().resource_mut::<CurrentTick>() {
t.0 = sim.current_tick();
}
sim.step();
}
Ok(())
}
struct CurrentTick(u64);
This pattern – define a component, register it, attach it on spawn, read/write it in a hook – is the standard way to add game-specific behavior to the simulation.
Next steps
Head to Configuration to learn about RON config files and the programmatic configuration API.
Configuration
So far we have been wiring up the simulation entirely in code. This chapter covers both that programmatic path and an alternative: RON config files loaded from disk. Both produce the same SimConfig under the hood, so everything the builder can do, a config file can express, and vice versa.
Programmatic configuration
The SimulationBuilder provides a fluent API for assembling a simulation in code. We have seen the basics already – here is a more complete example that customizes everything:
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
use elevator_core::dispatch::etd::EtdDispatch;
fn main() -> Result<(), SimError> {
let sim = SimulationBuilder::new()
.stop(StopId(0), "Ground", 0.0)
.stop(StopId(1), "Sky Lobby", 50.0)
.stop(StopId(2), "Observation Deck", 100.0)
// Two identical express cars — override only what differs from Default.
.elevator(ElevatorConfig {
id: 0,
name: "Express A".into(),
max_speed: 5.0,
acceleration: 2.0,
deceleration: 3.0,
weight_capacity: 1200.0,
starting_stop: StopId(0),
door_open_ticks: 60,
door_transition_ticks: 15,
..Default::default()
})
.elevator(ElevatorConfig {
id: 1,
name: "Express B".into(),
max_speed: 5.0,
acceleration: 2.0,
deceleration: 3.0,
weight_capacity: 1200.0,
starting_stop: StopId(2),
door_open_ticks: 60,
door_transition_ticks: 15,
..Default::default()
})
.building_name("Skyline Tower")
.ticks_per_second(60.0)
.dispatch(EtdDispatch::new())
.build()?;
Ok(())
}
ElevatorConfig fields
| Field | Type | Description | Default |
|---|---|---|---|
id | u32 | Unique numeric ID within the config (mapped to EntityId at runtime) | – |
name | String | Human-readable name for UIs and logs | – |
max_speed | f64 | Maximum travel speed (distance units/second) | 2.0 |
acceleration | f64 | Acceleration rate (distance units/second^2) | 1.5 |
deceleration | f64 | Deceleration rate (distance units/second^2) | 2.0 |
weight_capacity | f64 | Maximum total rider weight | 800.0 |
starting_stop | StopId | Where this elevator starts | – |
door_open_ticks | u32 | Ticks doors stay fully open | 10 |
door_transition_ticks | u32 | Ticks for a door open/close transition | 5 |
RON config files
For data-driven workflows, you can define your building in a RON file and load it at runtime. RON (Rusty Object Notation) is a human-readable format that maps directly to Rust structs.
Here is the default.ron included in the repository:
SimConfig(
building: BuildingConfig(
name: "Demo Tower",
stops: [
StopConfig(id: StopId(0), name: "Ground", position: 0.0),
StopConfig(id: StopId(1), name: "Floor 2", position: 4.0),
StopConfig(id: StopId(2), name: "Floor 3", position: 7.5),
StopConfig(id: StopId(3), name: "Floor 4", position: 11.0),
StopConfig(id: StopId(4), name: "Roof", position: 15.0),
],
),
elevators: [
ElevatorConfig(
id: 0,
name: "Main",
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(0),
door_open_ticks: 60,
door_transition_ticks: 15,
),
],
simulation: SimulationParams(
ticks_per_second: 60.0,
),
passenger_spawning: PassengerSpawnConfig(
mean_interval_ticks: 120,
weight_range: (50.0, 100.0),
),
)
Loading a RON file
Add ron to your dependencies (cargo add ron) since elevator-core re-uses it for config parsing but doesn’t re-export the deserializer:
use elevator_core::prelude::*;
use elevator_core::config::SimConfig;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let ron_str = std::fs::read_to_string("assets/config/default.ron")?;
let config: SimConfig = ron::from_str(&ron_str)?;
let sim = SimulationBuilder::from_config(config).build()?;
println!("Loaded simulation with {} tick rate",
sim.dt().recip() as u32);
Ok(())
}
SimulationBuilder::from_config(config) accepts a deserialized SimConfig and still lets you chain builder methods on top – for example, to override the dispatch strategy or register extensions.
Config sections
BuildingConfig
Defines the building layout. The stops list must have at least one entry, and each StopId must be unique. Stop positions are arbitrary f64 values along the shaft axis – they do not need to be uniformly spaced.
building: BuildingConfig(
name: "My Building",
stops: [
StopConfig(id: StopId(0), name: "Lobby", position: 0.0),
StopConfig(id: StopId(1), name: "Mezzanine", position: 2.5),
StopConfig(id: StopId(2), name: "Floor 2", position: 6.0),
],
),
ElevatorConfig (list)
One or more elevator cars. Each must reference a valid starting_stop from the building config. All numeric physics parameters must be positive.
SimulationParams
Controls simulation timing:
simulation: SimulationParams(
ticks_per_second: 60.0,
),
The tick rate determines dt (time delta per tick): dt = 1.0 / ticks_per_second. Higher values produce smoother motion but require more computation. 60 is a good default.
PassengerSpawnConfig
Advisory parameters for traffic generators. The core library does not spawn passengers automatically – these values are consumed by game code or the optional traffic feature:
passenger_spawning: PassengerSpawnConfig(
mean_interval_ticks: 120,
weight_range: (50.0, 100.0),
),
| Field | Meaning |
|---|---|
mean_interval_ticks | Average ticks between passenger spawns (Poisson distribution) |
weight_range | (min, max) for uniformly distributed rider weight |
The space elevator
To demonstrate that stops are truly arbitrary, the repository includes space_elevator.ron:
SimConfig(
building: BuildingConfig(
name: "Orbital Tether",
stops: [
StopConfig(id: StopId(0), name: "Ground Station", position: 0.0),
StopConfig(id: StopId(1), name: "Orbital Platform", position: 1000.0),
],
),
elevators: [
ElevatorConfig(
id: 0,
name: "Climber Alpha",
max_speed: 50.0,
acceleration: 10.0,
deceleration: 15.0,
weight_capacity: 10000.0,
starting_stop: StopId(0),
door_open_ticks: 120,
door_transition_ticks: 30,
),
],
// ...
)
The stops are 1,000 distance units apart, the elevator has a max speed of 50, and the doors take twice as long to cycle. The same simulation engine handles both a 5-story office and an orbital tether – the physics just scale.
Validation
Config is validated at construction time (in SimulationBuilder::build() or Simulation::new()). Invalid configs produce a SimError::InvalidConfig with a descriptive message. Validation checks include:
- At least one stop
- No duplicate
StopIdvalues - At least one elevator
- All physics parameters positive
- Each elevator’s
starting_stopreferences an existing stop - Tick rate is positive
Next steps
Head to Metrics and Events to learn how to observe what is happening inside your simulation.
Traffic Generation
Real simulations need rider arrivals. The traffic module (enabled by default via the traffic feature flag) provides tools for generating realistic passenger traffic — from uniform random spawns to time-varying daily patterns.
Traffic generation is external to the simulation loop. A TrafficSource produces SpawnRequests each tick; your code feeds them into the simulation. This keeps the core loop untouched and gives you full control over when and how riders spawn.
Quick start
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::traffic::{PoissonSource, TrafficPattern, TrafficSchedule};
fn main() -> Result<(), SimError> {
let mut sim = SimulationBuilder::new()
.stop(StopId(0), "Ground", 0.0)
.stop(StopId(1), "Top", 10.0)
.elevator(ElevatorConfig::default())
.build()?;
let stops: Vec<StopId> = sim.stop_lookup_iter().map(|(id, _)| *id).collect();
// Poisson arrivals with an office-day schedule.
let mut source = PoissonSource::new(
stops,
TrafficSchedule::office_day(3600), // 3600 ticks per hour
120, // mean inter-arrival: 120 ticks
(60.0, 90.0), // weight range: 60-90kg
);
for _ in 0..10_000 {
let tick = sim.current_tick();
for req in source.generate(tick) {
let _ = sim.spawn_rider_by_stop_id(req.origin, req.destination, req.weight);
}
sim.step();
}
Ok(())
}
Patterns
TrafficPattern selects origin/destination distributions. Five presets cover common building scenarios:
| Pattern | Distribution |
|---|---|
Uniform | Equal probability for all origin/destination pairs |
UpPeak | 80% from lobby, 20% inter-floor (morning rush) |
DownPeak | 80% to lobby, 20% inter-floor (evening rush) |
Lunchtime | 40% upper→mid, 40% mid→upper, 20% random |
Mixed | 30% up-peak, 30% down-peak, 40% inter-floor |
The first stop in the slice is treated as the “lobby” — make sure stops are sorted by position.
#![allow(unused)]
fn main() {
use elevator_core::traffic::TrafficPattern;
fn run(stops: &[elevator_core::stop::StopId]) {
let mut rng = rand::rng();
if let Some((origin, destination)) = TrafficPattern::UpPeak.sample_stop_ids(stops, &mut rng) {
println!("{origin} → {destination}");
}
}
}
Schedules
A TrafficSchedule maps tick ranges to patterns, enabling realistic daily cycles:
#![allow(unused)]
fn main() {
use elevator_core::traffic::{TrafficPattern, TrafficSchedule};
let schedule = TrafficSchedule::new(vec![
(0..3600, TrafficPattern::UpPeak), // First hour: morning rush
(3600..7200, TrafficPattern::Uniform), // Second hour: normal
(7200..10800, TrafficPattern::Lunchtime), // Third hour: lunch
(10800..14400, TrafficPattern::DownPeak), // Fourth hour: evening rush
]);
}
When the current tick falls outside all segments, the schedule uses a fallback pattern (default: Uniform):
#![allow(unused)]
fn main() {
use elevator_core::traffic::{TrafficPattern, TrafficSchedule};
let schedule = TrafficSchedule::new(vec![(0..1000, TrafficPattern::UpPeak)])
.with_fallback(TrafficPattern::Mixed);
}
Built-in schedule presets
TrafficSchedule::office_day(ticks_per_hour)— typical 9-hour office day with morning rush, lunch, and evening rushTrafficSchedule::constant(pattern)— a single pattern for all ticks
Poisson arrivals
PoissonSource is the default traffic generator. It uses exponential inter-arrival times — a standard Poisson process — driven by a mean interval parameter:
#![allow(unused)]
fn main() {
use elevator_core::traffic::{PoissonSource, TrafficSchedule, TrafficPattern};
use elevator_core::stop::StopId;
let stops = vec![StopId(0), StopId(1), StopId(2)];
let source = PoissonSource::new(
stops,
TrafficSchedule::constant(TrafficPattern::Uniform),
60, // mean arrival every 60 ticks
(50.0, 100.0), // weight range (min, max) in kg
);
}
Each call to source.generate(tick) returns a Vec<SpawnRequest> — zero, one, or multiple requests depending on how many arrivals are due since the last call.
From config
If your SimConfig already has passenger_spawning populated, use from_config:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::traffic::PoissonSource;
fn run(config: &SimConfig) {
let source = PoissonSource::from_config(config);
}
}
This uses the config’s mean_interval_ticks and weight_range, and defaults to a Uniform schedule. Override with .with_schedule(...) for time-varying traffic.
Fluent configuration
#![allow(unused)]
fn main() {
use elevator_core::traffic::{PoissonSource, TrafficSchedule, TrafficPattern};
use elevator_core::stop::StopId;
fn run(stops: Vec<StopId>) {
let source = PoissonSource::new(stops, TrafficSchedule::constant(TrafficPattern::Uniform), 100, (50.0, 100.0))
.with_schedule(TrafficSchedule::office_day(3600))
.with_mean_interval(50)
.with_weight_range((65.0, 85.0));
}
}
Determinism and seeding
PoissonSource uses a thread-local RNG (rand::rng()) internally, so two runs of the same config will produce different traffic. This is convenient for exploration but unsuitable for replay, regression testing, or research comparisons.
For reproducible traffic, write a custom TrafficSource that owns a seeded RNG:
#![allow(unused)]
fn main() {
use elevator_core::traffic::{TrafficSource, SpawnRequest, TrafficPattern};
use elevator_core::stop::StopId;
use rand::{Rng, SeedableRng, rngs::StdRng};
struct SeededPoisson {
stops: Vec<StopId>,
rng: StdRng,
mean_interval: u32,
next: u64,
}
impl SeededPoisson {
fn new(stops: Vec<StopId>, seed: u64, mean_interval: u32) -> Self {
let mut rng = StdRng::seed_from_u64(seed);
let next = rng.random_range(1..=(mean_interval * 2) as u64);
Self { stops, rng, mean_interval, next }
}
}
impl TrafficSource for SeededPoisson {
fn generate(&mut self, tick: u64) -> Vec<SpawnRequest> {
let mut out = Vec::new();
while tick >= self.next {
if let Some((origin, destination)) =
TrafficPattern::Uniform.sample_stop_ids(&self.stops, &mut self.rng)
{
out.push(SpawnRequest { origin, destination, weight: 75.0 });
}
self.next += self.rng.random_range(1..=(self.mean_interval * 2) as u64);
}
out
}
}
}
With a fixed seed, identical config, and a deterministic dispatch strategy, sim.snapshot() outputs byte-for-byte match across runs.
Custom traffic sources
The TrafficSource trait is trivial to implement for game-specific logic:
#![allow(unused)]
fn main() {
use elevator_core::traffic::{TrafficSource, SpawnRequest};
use elevator_core::stop::StopId;
/// Spawns a single VIP rider at a fixed tick.
struct VipSpawn {
tick: u64,
origin: StopId,
destination: StopId,
fired: bool,
}
impl TrafficSource for VipSpawn {
fn generate(&mut self, tick: u64) -> Vec<SpawnRequest> {
if !self.fired && tick >= self.tick {
self.fired = true;
vec![SpawnRequest {
origin: self.origin,
destination: self.destination,
weight: 85.0,
}]
} else {
Vec::new()
}
}
}
}
You can layer multiple sources, wrap them in a composite, or mix Poisson arrivals with scripted events. The simulation doesn’t care how requests are generated — only that you feed them in.
SpawnRequest
A SpawnRequest is the minimal description of a rider to spawn:
pub struct SpawnRequest {
pub origin: StopId,
pub destination: StopId,
pub weight: f64,
}
For riders that need patience, preferences, or access control, spawn through the simulation’s build_rider_by_stop_id fluent API instead of using spawn_rider_by_stop_id directly:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::traffic::SpawnRequest;
fn run(sim: &mut Simulation, req: SpawnRequest) -> Result<(), SimError> {
sim.build_rider_by_stop_id(req.origin, req.destination)?
.weight(req.weight)
.patience(600) // abandon after 10 seconds at 60 tps
.spawn()?;
Ok(())
}
}
RON configuration
TrafficPattern and TrafficSchedule derive Serialize/Deserialize, so you can include them in RON config files:
// traffic_config.ron
TrafficSchedule(
segments: [
(0..3600, UpPeak),
(3600..7200, Uniform),
(7200..10800, Lunchtime),
],
fallback: Uniform,
)
Next steps
Head to Metrics and Events to see how generated traffic produces events and summary statistics.
Metrics and Events
Once your simulation is configured and running, you need to know what it is doing. Every significant moment – a rider boarding, an elevator arriving, a door opening – produces a typed event. The metrics system aggregates these events into summary statistics. Together, events and metrics give you full observability into what your simulation is doing and how well it is performing.
The event system
What events fire
Events are emitted during the tick phases. Here are the main categories:
Elevator events:
| Event | When it fires |
|---|---|
ElevatorDeparted { elevator, from_stop, tick } | An elevator leaves a stop |
ElevatorArrived { elevator, at_stop, tick } | An elevator arrives at a stop |
DoorOpened { elevator, tick } | Doors finish opening |
DoorClosed { elevator, tick } | Doors finish closing |
PassingFloor { elevator, stop, moving_up, tick } | An elevator passes a stop without stopping |
ElevatorAssigned { elevator, stop, tick } | Dispatch assigns an elevator to a stop |
ElevatorRepositioning { elevator, to_stop, tick } | An idle elevator begins repositioning |
ElevatorRepositioned { elevator, at_stop, tick } | An elevator completed repositioning |
ElevatorIdle { elevator, at_stop, tick } | An elevator became idle |
CapacityChanged { elevator, current_load, capacity, tick } | An elevator’s load changed (after board or exit) |
DirectionIndicatorChanged { elevator, going_up, going_down, tick } | An elevator’s direction indicator lamps changed (set by dispatch) |
DestinationQueued { elevator, stop, tick } | A stop was pushed onto an elevator’s DestinationQueue (via dispatch or sim.push_destination*). Adjacent-duplicate pushes that are deduplicated do not emit. |
Rider events:
| Event | When it fires |
|---|---|
RiderSpawned { rider, origin, destination, tick } | A new rider appears at a stop |
RiderBoarded { rider, elevator, tick } | A rider enters an elevator |
RiderExited { rider, elevator, stop, tick } | A rider exits at their destination |
RiderRejected { rider, elevator, reason, context, tick } | A rider was refused boarding (over capacity) |
RiderAbandoned { rider, stop, tick } | A rider gave up waiting |
RiderEjected { rider, elevator, stop, tick } | A rider was ejected (elevator disabled) |
RiderSettled { rider, stop, tick } | A rider settled at a stop as a resident |
RiderDespawned { rider, tick } | A rider was removed from the simulation |
RiderRerouted { rider, new_destination, tick } | A rider was manually rerouted via sim.reroute() or sim.reroute_rider() |
Topology events:
| Event | When it fires |
|---|---|
StopAdded { stop, group, tick } | A stop was added at runtime |
ElevatorAdded { elevator, group, tick } | An elevator was added at runtime |
EntityDisabled { entity, tick } | An entity was disabled |
EntityEnabled { entity, tick } | An entity was re-enabled |
RouteInvalidated { rider, affected_stop, reason, tick } | A rider’s route was broken by a topology change |
LineAdded { line, group, tick } | A line was added |
LineRemoved { line, group, tick } | A line was removed |
LineReassigned { line, old_group, new_group, tick } | A line was moved between groups |
ElevatorReassigned { elevator, old_line, new_line, tick } | An elevator was moved between lines |
Draining events
Events are buffered during each tick and made available via sim.drain_events(). This returns a Vec<Event> and clears the buffer:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
fn run(sim: &mut Simulation) {
sim.step();
for event in sim.drain_events() {
match event {
Event::RiderBoarded { rider, elevator, tick } => {
println!("[{tick}] {rider:?} boarded {elevator:?}");
}
Event::RiderExited { rider, stop, tick, .. } => {
println!("[{tick}] {rider:?} arrived at {stop:?}");
}
Event::ElevatorArrived { elevator, at_stop, tick } => {
println!("[{tick}] {elevator:?} arrived at {at_stop:?}");
}
_ => {}
}
}
}
}
You can call drain_events() after every tick, every N ticks, or only when you need to – events accumulate until drained. The metrics system processes events independently each tick, so draining does not affect metric calculations.
Event ordering guarantees
- Within a tick: events fire in tick-phase order (Advance Transient → Dispatch → Reposition → Movement → Doors → Loading → Metrics). Events from a later phase are always later in the drained vec than events from an earlier phase of the same tick.
- Across ticks: events from tick N are drained before events from tick N+1. Every event carries its
tickfield, so you can reconstruct a strict timeline even if you drain in batches. - Within a phase: ordering between events of the same phase is stable but not part of the public contract — do not rely on, e.g., “elevator 0’s
RiderBoardedalways precedes elevator 1’s” across library versions. - Pair invariants:
RiderBoardedalways precedes the matchingRiderExitedfor the same rider;DoorOpenedalways precedesDoorClosedfor the same elevator at a given stop.
Buffer size and memory
drain_events() empties the internal buffer. If you never drain, the buffer grows unbounded — in long-running sims, drain at least periodically (every tick in most games, every N ticks in headless analyses). Each event is a small enum (tens of bytes); a 1M-rider simulation at 60 TPS produces on the order of a few million events over its run.
Building an event log
Here is a pattern for collecting a complete event log across a simulation run:
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
fn main() -> Result<(), SimError> {
let mut sim = SimulationBuilder::new()
.stop(StopId(0), "Lobby", 0.0)
.stop(StopId(1), "Floor 2", 4.0)
.stop(StopId(2), "Floor 3", 8.0)
.elevator(ElevatorConfig { starting_stop: StopId(0), ..Default::default() })
.build()?;
sim.spawn_rider_by_stop_id(StopId(0), StopId(2), 75.0)?;
sim.spawn_rider_by_stop_id(StopId(2), StopId(0), 80.0)?;
let mut event_log: Vec<Event> = Vec::new();
for _ in 0..1000 {
sim.step();
event_log.extend(sim.drain_events());
}
// Analyze the log.
let boardings = event_log.iter()
.filter(|e| matches!(e, Event::RiderBoarded { .. }))
.count();
let arrivals = event_log.iter()
.filter(|e| matches!(e, Event::RiderExited { .. }))
.count();
println!("Total boardings: {boardings}");
println!("Total arrivals: {arrivals}");
println!("Total events: {}", event_log.len());
Ok(())
}
Metrics
The Metrics struct aggregates key performance indicators across the entire simulation run. Access it via sim.metrics():
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
fn run(sim: &Simulation) {
let m = sim.metrics();
println!("Avg wait time: {:.1} ticks", m.avg_wait_time());
println!("Avg ride time: {:.1} ticks", m.avg_ride_time());
println!("Max wait time: {} ticks", m.max_wait_time());
println!("Throughput: {} riders/window", m.throughput());
println!("Total delivered: {}", m.total_delivered());
println!("Total spawned: {}", m.total_spawned());
println!("Total abandoned: {}", m.total_abandoned());
println!("Total settled: {}", m.total_settled());
println!("Total rerouted: {}", m.total_rerouted());
println!("Abandonment rate: {:.1}%", m.abandonment_rate() * 100.0);
println!("Total distance: {:.1} units", m.total_distance());
println!("Total moves: {}", m.total_moves());
}
}
What each metric means
| Metric | Description |
|---|---|
avg_wait_time() | Average ticks from spawn to board, across all riders that boarded |
avg_ride_time() | Average ticks from board to exit, across all delivered riders |
max_wait_time() | Longest wait observed (ticks) |
throughput() | Riders delivered in the current throughput window (default: 3600 ticks) |
total_delivered() | Cumulative riders successfully delivered |
total_spawned() | Cumulative riders spawned |
total_abandoned() | Cumulative riders who gave up waiting |
abandonment_rate() | total_abandoned / total_spawned (0.0 to 1.0) |
total_settled() | Cumulative riders settled as residents |
total_rerouted() | Cumulative riders rerouted from resident phase |
total_distance() | Sum of all elevator travel distance |
total_moves() | Total rounded-floor transitions across all elevators (passing-floor crossings + arrivals) |
utilization_by_group() | Per-group fraction of elevators currently moving |
avg_utilization() | Average utilization across all groups |
reposition_distance() | Total elevator distance traveled while repositioning |
Metrics are updated during the Metrics phase of each tick. They are always available and always reflect the latest tick, regardless of whether you drain events.
Compact summary
Metrics implements Display for a one-line KPI summary suitable for HUDs and logs:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
fn run(sim: &Simulation) {
println!("{}", sim.metrics());
// Output: "42 delivered, avg wait 87.3t, 65% util"
}
}
Inspection queries
The Simulation exposes several read-only query helpers for game UIs and dispatch logic.
Entity type checks
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
fn run(sim: &Simulation, id: EntityId) {
if sim.is_elevator(id) {
// ...
} else if sim.is_rider(id) {
// ...
} else if sim.is_stop(id) {
// ...
}
}
}
Aggregate queries
Common KPIs that games typically display in HUDs:
| Method | Returns |
|---|---|
sim.idle_elevator_count() | Count of elevators currently idle (excludes disabled) |
sim.elevators_in_phase(phase) | Count of elevators in a given phase (excludes disabled) |
sim.elevator_load(id) | Current total weight aboard an elevator, None if not an elevator |
sim.elevator_move_count(id) | Per-elevator count of rounded-floor transitions, None if not an elevator |
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
fn run(sim: &Simulation) {
let idle = sim.idle_elevator_count();
let loading = sim.elevators_in_phase(ElevatorPhase::Loading);
println!("{idle} idle, {loading} loading");
}
}
Disabled elevators are excluded from phase counts — their phase is reset to Idle on disable, but they should not appear as “available” in game UIs.
Converting ticks to seconds
Metrics are reported in ticks. To convert to wall-clock seconds, use the TimeAdapter:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
fn run(sim: &Simulation) {
let time = sim.time();
let avg_wait_seconds = time.ticks_to_seconds(sim.metrics().avg_wait_time() as u64);
println!("Avg wait: {avg_wait_seconds:.1}s");
}
}
Tagged metrics
For per-zone or per-label breakdowns, you can tag entities with string labels and query metrics scoped to those tags.
Tagging entities
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
fn main() -> Result<(), SimError> {
let mut sim = SimulationBuilder::new()
.stop(StopId(0), "Ground", 0.0)
.stop(StopId(1), "Top", 10.0)
.elevator(ElevatorConfig::default())
.build()?;
// Tag a stop.
let lobby = sim.stop_entity(StopId(0)).unwrap();
sim.tag_entity(lobby, "zone:lobby");
// Tag a rider (riders auto-inherit tags from their origin stop when spawned).
let rider = sim.spawn_rider_by_stop_id(StopId(0), StopId(1), 75.0)?;
// rider automatically has "zone:lobby" because it was spawned at StopId(0).
// You can also tag manually.
sim.tag_entity(rider, "priority:vip");
Ok(())
}
Querying tagged metrics
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
fn run(sim: &Simulation) {
if let Some(lobby_metrics) = sim.metrics_for_tag("zone:lobby") {
println!("Lobby avg wait: {:.1} ticks", lobby_metrics.avg_wait_time());
println!("Lobby delivered: {}", lobby_metrics.total_delivered());
println!("Lobby abandoned: {}", lobby_metrics.total_abandoned());
println!("Lobby max wait: {} ticks", lobby_metrics.max_wait_time());
}
}
}
Tagged metrics track avg_wait_time, total_delivered, total_abandoned, total_spawned, and max_wait_time per tag. They are updated automatically during the Metrics phase.
Practical example: comparing zones
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
fn main() -> Result<(), SimError> {
let mut sim = SimulationBuilder::new()
.stop(StopId(0), "Lobby", 0.0)
.stop(StopId(1), "Low Zone", 4.0)
.stop(StopId(2), "Mid Zone", 8.0)
.stop(StopId(3), "High Zone", 12.0)
.elevator(ElevatorConfig { starting_stop: StopId(0), ..Default::default() })
.build()?;
// Tag stops by zone.
for (id, tag) in [(0, "zone:low"), (1, "zone:low"), (2, "zone:high"), (3, "zone:high")] {
if let Some(eid) = sim.stop_entity(StopId(id)) {
sim.tag_entity(eid, tag);
}
}
// Spawn riders from different zones.
sim.spawn_rider_by_stop_id(StopId(0), StopId(3), 75.0)?;
sim.spawn_rider_by_stop_id(StopId(3), StopId(0), 80.0)?;
// Run the simulation.
for _ in 0..2000 {
sim.step();
sim.drain_events(); // clear the buffer
}
// Compare zone performance.
for zone in ["zone:low", "zone:high"] {
if let Some(m) = sim.metrics_for_tag(zone) {
println!("{zone}: avg_wait={:.0} delivered={} abandoned={}",
m.avg_wait_time(), m.total_delivered(), m.total_abandoned());
}
}
Ok(())
}
Next steps
Head to Bevy Integration to see how the companion crate wraps all of this into a visual Bevy application.
Snapshots and Determinism
elevator-core is designed for reproducible simulations. This chapter covers the determinism guarantees, the snapshot save/load API, and the patterns for replay, regression testing, and research comparisons.
Determinism guarantee
The simulation is deterministic given:
- The same initial
SimConfig(same stops, elevators, groups, lines, dispatch strategy). - The same sequence of API calls (
spawn_rider,despawn_rider,tag_entity, hook mutations, etc.). - A deterministic dispatch strategy (the four built-ins —
ScanDispatch,LookDispatch,NearestCarDispatch,EtdDispatch— are deterministic).
Under those conditions two runs produce byte-identical snapshots and event streams.
Sources of non-determinism to watch for:
PoissonSourceand similar traffic generators use a thread-local RNG. See Traffic Generation → Determinism and seeding.- Custom dispatch strategies or hooks that read wall-clock time, thread IDs, or unseeded RNGs.
- HashMap iteration order in your own hook code (the sim itself uses stable iteration).
Snapshots
A WorldSnapshot captures the full simulation state — all entities, components, groups, lines, metrics, tagged metrics, tick counter — in a serializable struct. Extension components are captured by type name and need a matching registration on restore. Resources and hooks are not captured.
Saving
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
fn main() -> Result<(), SimError> {
let mut sim = SimulationBuilder::new()
.stop(StopId(0), "Ground", 0.0)
.stop(StopId(1), "Top", 10.0)
.elevator(ElevatorConfig::default())
.build()?;
for _ in 0..1000 { sim.step(); }
let snapshot = sim.snapshot();
let bytes = ron::to_string(&snapshot).unwrap();
std::fs::write("save.ron", bytes).unwrap();
Ok(())
}
The snapshot struct is Serialize + Deserialize — choose any serde format (RON, JSON, bincode, postcard).
Loading
use elevator_core::prelude::*;
use elevator_core::snapshot::WorldSnapshot;
fn main() -> Result<(), SimError> {
let bytes = std::fs::read_to_string("save.ron").unwrap();
let snapshot: WorldSnapshot = ron::from_str(&bytes).unwrap();
// `None` means "only built-in dispatch strategies"; pass a closure to
// resurrect custom strategies registered by name.
let sim = snapshot.restore(None);
Ok(())
}
Custom dispatch across restore
Built-in strategies (Scan, Look, NearestCar, Etd) are auto-restored by name. Custom strategies need a factory:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::snapshot::WorldSnapshot;
use elevator_core::world::World;
struct HighestFirstDispatch;
impl DispatchStrategy for HighestFirstDispatch {
fn decide(&mut self, _: EntityId, _: f64, _: &elevator_core::dispatch::ElevatorGroup, _: &DispatchManifest, _: &World) -> DispatchDecision { DispatchDecision::Idle }
}
fn run(snapshot: WorldSnapshot) {
let sim = snapshot.restore(Some(&|name: &str| match name {
"HighestFirst" => Some(Box::new(HighestFirstDispatch)),
_ => None,
}));
}
}
Custom strategies are registered with BuiltinStrategy::Custom("name") via sim.set_dispatch(group, Box::new(HighestFirstDispatch), BuiltinStrategy::Custom("HighestFirst".into())). That registered name is what the snapshot stores and what the factory closure receives on restore — make sure the two match.
Extensions across restore
Extensions are serialized by their registered name. To restore them, re-register on the restored simulation’s world and then call load_extensions:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::snapshot::WorldSnapshot;
use serde::{Serialize, Deserialize};
#[derive(Clone, Serialize, Deserialize)] struct VipTag;
fn run(snapshot: WorldSnapshot) {
let mut sim = snapshot.restore(None);
sim.world_mut().register_ext::<VipTag>("vip_tag");
sim.load_extensions();
}
}
Use the register_extensions! macro to register many types in one line.
Patterns
Replay
- Serialize the initial config.
- Log every external mutation (
spawn_rider,despawn_rider, tag changes) with its tick. - To replay: rebuild the sim from config, then step while replaying logged mutations at the right ticks.
Snapshots are a stronger alternative — you can start replay from any tick by restoring a snapshot taken at that tick.
Regression testing
Run a seeded scenario for N ticks, snapshot, and diff against a golden snapshot:
let snap = sim.snapshot();
let actual = ron::to_string(&snap).unwrap();
let expected = include_str!("../golden/scenario_a.ron");
assert_eq!(actual, expected);
This catches unintended behavior changes anywhere in the tick pipeline.
Research comparisons
To compare dispatch strategies fairly, use identical seeded traffic across runs:
#![allow(unused)]
fn main() {
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 build_sim(dispatch: impl DispatchStrategy + 'static) -> Simulation {
SimulationBuilder::new()
.stop(StopId(0), "Ground", 0.0)
.stop(StopId(1), "Top", 10.0)
.elevator(ElevatorConfig::default())
.dispatch(dispatch)
.build()
.unwrap()
}
fn run_with(sim: &mut Simulation) {}
let mut scan_sim = build_sim(ScanDispatch::new());
let mut etd_sim = build_sim(EtdDispatch::new());
run_with(&mut scan_sim); // same seed, same traffic source construction
run_with(&mut etd_sim);
// Compare metrics side-by-side.
}
Next steps
See Performance for scaling guidance and benchmark interpretation.
Performance
This chapter covers complexity, memory, and practical scaling guidance. The core is designed to handle tens of thousands of riders per tick on a single thread, with per-tick cost dominated by the dispatch strategy you choose.
Complexity overview
Let E = elevators, R = riders, S = stops. Per sim.step():
| Phase | Cost | Notes |
|---|---|---|
| Advance transient | O(R) worst-case, O(transitioning riders) typical | Only touches riders in Boarding/Exiting. |
| Dispatch (SCAN/LOOK) | O(E · S) | Constant work per elevator per stop in the group. |
| Dispatch (NearestCar) | O(E · S) | Uses decide_all to coordinate. |
| Dispatch (ETD) | O(E · S · R_waiting) | Estimates per-rider delays; heaviest built-in. |
| Reposition | O(E · S) | Only runs if configured. |
| Movement | O(E) | Pure arithmetic per elevator. |
| Doors | O(E) | Door FSM per elevator. |
| Loading | O(boarding + exiting at each open door) | Uses the rider index for per-stop queues. |
| Metrics | O(events this tick) | Linear in the event count. |
Population queries (residents_at, waiting_at, abandoned_at) are O(1) via the rider index, so UIs and hooks can poll them every tick without penalty.
Memory
Rough per-entity memory (native x86_64, with default components):
| Entity | Bytes (approx.) |
|---|---|
| Stop | ~120 |
| Elevator | ~200 |
Rider (with Route and Patience) | ~160 |
Add your own extension components on top. A 10k-rider simulation with a dozen stops and a handful of elevators fits comfortably under 5 MB of live state.
The event buffer grows until drain_events() is called — see Metrics and Events → Buffer size and memory.
Benchmarks
The crate ships a benchmark suite (Criterion) in crates/elevator-core/benches/:
| Bench | Measures |
|---|---|
sim_bench | End-to-end step() across representative scenarios |
scaling_bench | Throughput vs. rider count |
dispatch_bench | Per-strategy cost at a fixed rider load |
multi_line_bench | Multi-group, multi-line buildings |
query_bench | Population and lookup queries |
Run with:
cargo bench -p elevator-core
Results go to target/criterion/ with HTML reports. A nightly GitHub
Actions job (.github/workflows/bench-nightly.yml) reruns the full
suite daily, caches a baseline, and opens an issue when Criterion
flags a significant regression. There is no PR gate — bench noise on
shared runners tends to swamp a strict per-PR check.
Current baselines
Measured on a 32-core Linux x86_64 workstation (Rust stable, release profile, Criterion defaults: 3 s warmup, 5 s measurement). Numbers are the Criterion median unless noted. Shared-runner numbers will be noisier; treat these as orders of magnitude, not tight SLAs.
Primitives
| Item | Time |
|---|---|
tick_movement (single call) | ~1.3 ns |
sim_bench / dispatch / 3e_10s | ~4.0 µs |
sim_bench / dispatch / 10e_50s | ~12 µs |
Full tick throughput (scaling_bench)
| Scenario | Time per run | Per tick |
|---|---|---|
| 50 elevators, 200 stops, 2 000 riders, 100 ticks | ~14 ms | ~143 µs/tick |
| 500 elevators, 5 000 stops, 50 000 riders, 10 ticks | ~520 ms | ~52 ms/tick |
| 10 000-rider spawn pressure test | ~4.9 ms | — |
The realistic row is the one most consumers should care about: a medium office tower with 2 000 concurrent riders runs the full 8-phase tick in well under a millisecond on a single core.
Dispatch strategy comparison (dispatch_bench)
Per step() cost at three scales, holding everything else constant:
| Scale | SCAN | LOOK | NearestCar | ETD |
|---|---|---|---|---|
| 5e, 10s | 61 µs | 67 µs | 63 µs | 66 µs |
| 20e, 50s | 436 µs | 395 µs | 423 µs | 413 µs |
| 50e, 200s | 2.18 ms | 2.00 ms | 2.04 ms | 1.96 ms |
The four built-in strategies land within ~15 % of each other at every scale. ETD is competitive despite its richer cost model because the other phases dominate wall-clock time. Pick the strategy that fits your dispatch behavior needs; they’re all fast enough.
Query surface (query_bench)
O(n) over entity population, as the API docs promise:
| Query | 100 | 1 000 | 10 000 |
|---|---|---|---|
query<Rider> | 13 µs | 60 µs | 744 µs |
query_tuple<&Rider, &Patience> | 12 µs | 52 µs | 859 µs |
query_elevators (10/50/200) | 4 µs | 5 µs | 13 µs |
Population queries on RiderIndex (residents_at / waiting_at /
abandoned_at) are O(1) and don’t appear here — they run in tens of
nanoseconds.
Multi-group topology (multi_line_bench)
| Scenario | Time |
|---|---|
multi_3g_2l_5e_20s / step() | ~920 µs |
cross_group_routing / 10 groups | ~330 µs |
topology_queries / reachable_stops_from | ~177 µs |
topology_queries / shortest_route | ~161 µs |
dynamic_topology / add_line | ~2.5 µs |
dynamic_topology / topology_rebuild | ~21 µs |
Runtime topology mutations (add_line, remove_line, add_stop_to_line)
are single-digit microseconds because the graph is rebuilt lazily on
next query, not eagerly on every mutation.
Use these as a baseline when writing custom dispatch strategies — if
your strategy’s dispatch_bench time is 10× the ETD baseline, expect
a 10× slowdown in loaded simulations.
Scaling checklist
For simulations above ~10k concurrent riders or above ~50 elevators:
- Pick the cheapest dispatch strategy that meets your needs.
NearestCarDispatchis usually a better default than ETD at scale. - Split into groups. Each group dispatches independently; two groups of 20 elevators each is cheaper than one group of 40.
- Drain events every tick (or redirect into a bounded ring buffer) to keep memory flat.
- Avoid heavy work in hooks. A hook that iterates all riders every tick is O(R) on top of the dispatch cost — prefer extension-attached flags you can toggle on-event.
- Profile before optimizing. The Criterion benches make it straightforward to identify the hot phase — dispatch dominates far more often than movement or doors.
What we do not provide
- Parallelism. The tick loop is single-threaded by design (determinism > throughput). Run multiple sims in parallel across threads if you need more aggregate work.
- GPU acceleration. Movement and dispatch are scalar — no SIMD or GPU backends.
- Persistent indexes beyond per-stop population. If you need “all riders with extension X”, iterate and filter.
Next steps
Head to Bevy Integration for a visual wrapper, or API Reference for the full API surface.
API Reference
This chapter is a quick-reference for the public API of the elevator-core crate. It covers every public type, method, and enum variant you are likely to reach for when building on the simulation. For full rustdoc with source links, inline examples, and trait impls, see docs.rs/elevator-core.
Prelude
use elevator_core::prelude::*; re-exports these items. Anything else must be imported from its module explicitly.
| Category | Items |
|---|---|
| Builder & sim | SimulationBuilder, Simulation, RiderBuilder |
| Components | Rider, RiderPhase, Elevator, ElevatorPhase, Stop, Line, Position, Velocity, FloorPosition, Route, Patience, Preferences, AccessControl, Orientation, ServiceMode |
| Config | SimConfig, GroupConfig, LineConfig |
| Dispatch traits | DispatchStrategy, RepositionStrategy |
| Reposition strategies | NearestIdle, ReturnToLobby, SpreadEvenly, DemandWeighted |
| Identity | EntityId, StopId, GroupId |
| Errors & events | SimError, RejectionReason, RejectionContext, Event, EventBus |
| Misc | Metrics, TimeAdapter |
Not in the prelude (import explicitly):
elevator_core::dispatch::scan::ScanDispatch,dispatch::look::LookDispatch,dispatch::nearest_car::NearestCarDispatch,dispatch::etd::EtdDispatchelevator_core::config::{ElevatorConfig, StopConfig}elevator_core::traffic::*(feature-gated behindtraffic)elevator_core::snapshot::WorldSnapshotelevator_core::world::World(parameter type for custom dispatch)
SimulationBuilder
Fluent builder for constructing a Simulation. Starts with a minimal valid config (2 stops, 1 elevator, SCAN dispatch, 60 TPS). Override any part before calling build().
| Method | Signature | Description |
|---|---|---|
new | () -> Self | Create a builder with minimal defaults |
from_config | (SimConfig) -> Self | Create a builder from an existing config |
stops | (Vec<StopConfig>) -> Self | Replace all stops |
stop | (StopId, impl Into<String>, f64) -> Self | Add a single stop |
elevators | (Vec<ElevatorConfig>) -> Self | Replace all elevators |
elevator | (ElevatorConfig) -> Self | Add a single elevator |
ticks_per_second | (f64) -> Self | Set the tick rate |
building_name | (impl Into<String>) -> Self | Set the building name |
dispatch | (impl DispatchStrategy) -> Self | Set dispatch for the default group |
dispatch_for_group | (GroupId, impl DispatchStrategy) -> Self | Set dispatch for a specific group |
before | (Phase, impl Fn(&mut World)) -> Self | Register a before-phase hook |
after | (Phase, impl Fn(&mut World)) -> Self | Register an after-phase hook |
before_group | (Phase, GroupId, impl Fn(&mut World)) -> Self | Before-phase hook for a specific group |
after_group | (Phase, GroupId, impl Fn(&mut World)) -> Self | After-phase hook for a specific group |
line | (LineConfig) -> Self | Add a single line configuration (switches to explicit topology mode) |
lines | (Vec<LineConfig>) -> Self | Replace all lines |
group | (GroupConfig) -> Self | Add a single group configuration |
groups | (Vec<GroupConfig>) -> Self | Replace all groups |
with_ext::<T> | (&str) -> Self | Pre-register an extension type for snapshot deserialization |
build | () -> Result<Simulation, SimError> | Validate config and build the simulation |
Simulation
The core simulation state. Advance it by calling step(), or run individual phases for fine-grained control.
Stepping
| Method | Signature | Description |
|---|---|---|
step | (&mut self) | Run all 8 phases and advance the tick counter |
advance_tick | (&mut self) | Increment tick counter and flush events to output buffer |
run_advance_transient | (&mut self) | Run the advance-transient phase (with hooks) |
run_dispatch | (&mut self) | Run the dispatch phase (with hooks) |
run_reposition | (&mut self) | Run the reposition phase (with hooks) |
run_advance_queue | (&mut self) | Run the advance-queue phase — reconcile DestinationQueue (with hooks) |
run_movement | (&mut self) | Run the movement phase (with hooks) |
run_doors | (&mut self) | Run the doors phase (with hooks) |
run_loading | (&mut self) | Run the loading phase (with hooks) |
run_metrics | (&mut self) | Run the metrics phase (with hooks) |
phase_context | (&self) -> PhaseContext | Build the tick/dt context for the current tick |
Rider Management
| Method | Signature | Description |
|---|---|---|
spawn_rider | (&mut self, EntityId, EntityId, f64) -> Result<EntityId, SimError> | Spawn a rider at origin heading to destination; auto-detects group (returns AmbiguousRoute if multiple groups serve both stops) |
spawn_rider_with_route | (&mut self, EntityId, EntityId, f64, Route) -> Result<EntityId, SimError> | Spawn a rider with an explicit route |
spawn_rider_by_stop_id | (&mut self, StopId, StopId, f64) -> Result<EntityId, SimError> | Spawn a rider using config StopIds |
spawn_rider_in_group | (&mut self, EntityId, EntityId, f64, GroupId) -> Result<EntityId, SimError> | Spawn a rider in a specific group |
spawn_rider_in_group_by_stop_id | (&mut self, StopId, StopId, f64, GroupId) -> Result<EntityId, SimError> | Spawn a rider in a specific group using config StopIds |
reroute | (&mut self, EntityId, EntityId) -> Result<(), SimError> | Change a waiting rider’s destination |
set_rider_route | (&mut self, EntityId, Route) -> Result<(), SimError> | Replace a rider’s entire remaining route |
Events
| Method | Signature | Description |
|---|---|---|
drain_events | (&mut self) -> Vec<Event> | Drain all pending events from completed ticks |
pending_events | (&self) -> &[Event] | Peek at pending events without draining |
events_mut | (&mut self) -> &mut EventBus | Get a mutable reference to the internal event bus |
Metrics and Tagging
| Method | Signature | Description |
|---|---|---|
metrics | (&self) -> &Metrics | Get current aggregate metrics |
metrics_mut | (&mut self) -> &mut Metrics | Get mutable access to metrics |
metrics_for_tag | (&self, &str) -> Option<&TaggedMetric> | Query per-tag metric accumulator |
tag_entity | (&mut self, EntityId, impl Into<String>) | Attach a metric tag to an entity |
untag_entity | (&mut self, EntityId, &str) | Remove a metric tag from an entity |
all_tags | (&self) -> Vec<&str> | List all registered metric tags |
Dynamic Topology
| Method | Signature | Description |
|---|---|---|
add_stop | (&mut self, String, f64, EntityId) -> Result<EntityId, SimError> | Add a new stop to a line at runtime |
add_elevator | (&mut self, &ElevatorParams, EntityId, f64) -> Result<EntityId, SimError> | Add a new elevator to a line at runtime |
add_line | (&mut self, &LineParams) -> Result<EntityId, SimError> | Add a new line to a group at runtime |
remove_line | (&mut self, EntityId) -> Result<(), SimError> | Remove a line and disable its elevators |
add_group | (&mut self, impl Into<String>, impl DispatchStrategy) -> GroupId | Create a new dispatch group |
assign_line_to_group | (&mut self, EntityId, GroupId) -> Result<GroupId, SimError> | Reassign a line to a different group; returns old GroupId |
add_stop_to_line | (&mut self, EntityId, EntityId) -> Result<(), SimError> | Add an existing stop to a line’s served-stop list |
remove_stop_from_line | (&mut self, EntityId, EntityId) -> Result<(), SimError> | Remove a stop from a line’s served-stop list |
disable | (&mut self, EntityId) -> Result<(), SimError> | Disable an entity (skipped by all systems; ejects riders from elevators) |
enable | (&mut self, EntityId) -> Result<(), SimError> | Re-enable a disabled entity |
is_disabled | (&self, EntityId) -> bool | Check if an entity is disabled |
Topology Queries
| Method | Signature | Description |
|---|---|---|
all_lines | (&self) -> Vec<EntityId> | All line entities in the simulation |
line_count | (&self) -> usize | Number of lines in the simulation |
lines_in_group | (&self, GroupId) -> Vec<EntityId> | Line entities belonging to a group |
elevators_on_line | (&self, EntityId) -> Vec<EntityId> | Elevator entities on a line |
stops_served_by_line | (&self, EntityId) -> Vec<EntityId> | Stop entities served by a line |
line_for_elevator | (&self, EntityId) -> Option<EntityId> | Find the line an elevator belongs to |
lines_serving_stop | (&self, EntityId) -> Vec<EntityId> | Lines that serve a given stop |
groups_serving_stop | (&self, EntityId) -> Vec<GroupId> | Groups that serve a given stop |
reachable_stops_from | (&self, EntityId) -> Vec<EntityId> | All stops reachable from a stop (via any line/transfer) |
transfer_points | (&self) -> Vec<EntityId> | Stops served by more than one group |
shortest_route | (&self, EntityId, EntityId) -> Option<Route> | Compute the shortest route between two stops |
Accessors
| Method | Signature | Description |
|---|---|---|
world | (&self) -> &World | Shared reference to the ECS world |
world_mut | (&mut self) -> &mut World | Mutable reference to the ECS world |
current_tick | (&self) -> u64 | Current simulation tick |
dt | (&self) -> f64 | Time delta per tick in seconds |
groups | (&self) -> &[ElevatorGroup] | Get the elevator groups |
stop_entity | (&self, StopId) -> Option<EntityId> | Resolve a config StopId to its runtime EntityId |
stop_lookup_iter | (&self) -> impl Iterator<Item = (&StopId, &EntityId)> | Iterate the stop ID to entity ID mapping |
time | (&self) -> &TimeAdapter | Tick-to-wall-clock time converter |
strategy_id | (&self, GroupId) -> Option<&BuiltinStrategy> | Get the dispatch strategy identifier for a group |
dispatchers | (&self) -> &BTreeMap<GroupId, Box<dyn DispatchStrategy>> | Get the dispatch strategies map |
dispatchers_mut | (&mut self) -> &mut BTreeMap<GroupId, Box<dyn DispatchStrategy>> | Get the dispatch strategies map mutably |
Inspection Queries
| Method | Signature | Description |
|---|---|---|
is_elevator | (&self, EntityId) -> bool | Check if an entity has an Elevator component |
is_rider | (&self, EntityId) -> bool | Check if an entity has a Rider component |
is_stop | (&self, EntityId) -> bool | Check if an entity has a Stop component |
is_disabled | (&self, EntityId) -> bool | Check if an entity is disabled |
idle_elevator_count | (&self) -> usize | Count of elevators in Idle phase (excludes disabled) |
elevators_in_phase | (&self, ElevatorPhase) -> usize | Count of elevators in a given phase (excludes disabled) |
elevator_load | (&self, EntityId) -> Option<f64> | Current weight aboard an elevator |
elevator_going_up | (&self, EntityId) -> Option<bool> | Up-direction indicator lamp state (None if not an elevator) |
elevator_going_down | (&self, EntityId) -> Option<bool> | Down-direction indicator lamp state (None if not an elevator) |
elevator_move_count | (&self, EntityId) -> Option<u64> | Per-elevator count of rounded-floor transitions (None if not an elevator) |
braking_distance | (&self, EntityId) -> Option<f64> | Distance required to brake to a stop from the current velocity at the elevator’s deceleration (v² / 2a). Some(0.0) when stationary; None if not an elevator |
future_stop_position | (&self, EntityId) -> Option<f64> | Current position plus signed braking distance in the direction of travel — where the elevator would come to rest if braking began now |
Dispatch
| Method | Signature | Description |
|---|---|---|
set_dispatch | (&mut self, GroupId, Box<dyn DispatchStrategy>, BuiltinStrategy) | Replace the dispatch strategy for a group |
Hooks (Post-Build)
| Method | Signature | Description |
|---|---|---|
add_before_hook | (&mut self, Phase, impl Fn(&mut World)) | Register a hook to run before a phase |
add_after_hook | (&mut self, Phase, impl Fn(&mut World)) | Register a hook to run after a phase |
add_before_group_hook | (&mut self, Phase, GroupId, impl Fn(&mut World)) | Before-phase hook for a specific group |
add_after_group_hook | (&mut self, Phase, GroupId, impl Fn(&mut World)) | After-phase hook for a specific group |
Snapshots
| Method | Signature | Description |
|---|---|---|
snapshot | (&self) -> WorldSnapshot | Create a serializable snapshot of the current state |
load_extensions | (&mut self) | Deserialize extension components from a pending snapshot |
ElevatorParams
Parameters for add_elevator at runtime. All fields are public.
| Field | Type | Default | Description |
|---|---|---|---|
max_speed | f64 | 2.0 | Maximum travel speed |
acceleration | f64 | 1.5 | Acceleration rate |
deceleration | f64 | 2.0 | Deceleration rate |
weight_capacity | f64 | 800.0 | Maximum weight the car can carry |
door_transition_ticks | u32 | 5 | Ticks for a door open/close transition |
door_open_ticks | u32 | 10 | Ticks the door stays fully open |
LineParams
Parameters for add_line at runtime. All fields are public.
| Field | Type | Description |
|---|---|---|
name | String | Human-readable name |
group | GroupId | Dispatch group to add this line to |
orientation | Orientation | Physical orientation (defaults to Vertical) |
min_position | f64 | Lowest reachable position on the line axis |
max_position | f64 | Highest reachable position on the line axis |
position | Option<FloorPosition> | Optional floor-plan position |
max_cars | Option<usize> | Maximum cars on this line (None = unlimited) |
Constructor: LineParams::new(name, group) — defaults orientation to Vertical, positions to 0.0, no floor-plan position, unlimited cars.
World
Central entity/component storage using the struct-of-arrays pattern. Built-in components are accessed via typed methods; custom data goes through extension storage.
Entity Lifecycle
| Method | Signature | Description |
|---|---|---|
new | () -> Self | Create an empty world |
spawn | (&mut self) -> EntityId | Allocate a new entity (no components attached) |
despawn | (&mut self, EntityId) | Remove an entity and all its components |
is_alive | (&self, EntityId) -> bool | Check if an entity is alive |
entity_count | (&self) -> usize | Number of live entities |
Component Accessors
Each built-in component has a getter, mutable getter, and setter. The pattern is the same for all:
| Component | Get | Get Mut | Set |
|---|---|---|---|
Position | position(EntityId) -> Option<&Position> | position_mut(EntityId) | set_position(EntityId, Position) |
Velocity | velocity(EntityId) -> Option<&Velocity> | velocity_mut(EntityId) | set_velocity(EntityId, Velocity) |
Elevator | elevator(EntityId) -> Option<&Elevator> | elevator_mut(EntityId) | set_elevator(EntityId, Elevator) |
Rider | rider(EntityId) -> Option<&Rider> | rider_mut(EntityId) | set_rider(EntityId, Rider) |
Stop | stop(EntityId) -> Option<&Stop> | stop_mut(EntityId) | set_stop(EntityId, Stop) |
Route | route(EntityId) -> Option<&Route> | route_mut(EntityId) | set_route(EntityId, Route) |
Line | line(EntityId) -> Option<&Line> | line_mut(EntityId) | set_line(EntityId, Line) |
Patience | patience(EntityId) -> Option<&Patience> | patience_mut(EntityId) | set_patience(EntityId, Patience) |
Preferences | preferences(EntityId) -> Option<&Preferences> | – | set_preferences(EntityId, Preferences) |
Iteration Helpers
| Method | Signature | Description |
|---|---|---|
iter_elevators | (&self) -> impl Iterator<Item = (EntityId, &Position, &Elevator)> | Iterate all elevator entities |
iter_riders | (&self) -> impl Iterator<Item = (EntityId, &Rider)> | Iterate all rider entities |
iter_riders_mut | (&mut self) -> impl Iterator<Item = (EntityId, &mut Rider)> | Iterate all rider entities mutably |
iter_stops | (&self) -> impl Iterator<Item = (EntityId, &Stop)> | Iterate all stop entities |
elevator_ids | (&self) -> Vec<EntityId> | All elevator entity IDs |
rider_ids | (&self) -> Vec<EntityId> | All rider entity IDs |
stop_ids | (&self) -> Vec<EntityId> | All stop entity IDs |
elevator_ids_into | (&self, &mut Vec<EntityId>) | Fill a buffer with elevator IDs (no allocation) |
Stop Lookup
| Method | Signature | Description |
|---|---|---|
find_stop_at_position | (&self, f64) -> Option<EntityId> | Find the stop at an exact position (within epsilon) |
find_nearest_stop | (&self, f64) -> Option<EntityId> | Find the stop nearest to a position |
stop_position | (&self, EntityId) -> Option<f64> | Get a stop’s position by entity ID |
Extension Storage
Extensions let games attach custom typed data to simulation entities. Extension types must implement Serialize + DeserializeOwned for snapshot support.
| Method | Signature | Description |
|---|---|---|
insert_ext | <T>(&mut self, EntityId, T, &str) | Insert a custom component for an entity |
get_ext | <T: Clone>(&self, EntityId) -> Option<T> | Get a clone of a custom component |
get_ext_mut | <T>(&mut self, EntityId) -> Option<&mut T> | Get a mutable reference to a custom component |
remove_ext | <T>(&mut self, EntityId) -> Option<T> | Remove a custom component |
register_ext | <T>(&mut self, &str) | Register an extension type for snapshot deserialization |
query_ext_mut | <T>(&mut self) -> ExtQueryMut<T> | Create a mutable extension query builder |
Global Resources
Resources are type-keyed singletons not attached to any entity. Useful for event channels, score trackers, or any shared state.
| Method | Signature | Description |
|---|---|---|
insert_resource | <T>(&mut self, T) | Insert a global resource (replaces existing) |
resource | <T>(&self) -> Option<&T> | Get a shared reference to a resource |
resource_mut | <T>(&mut self) -> Option<&mut T> | Get a mutable reference to a resource |
remove_resource | <T>(&mut self) -> Option<T> | Remove a resource, returning it |
Query Builder
The query builder provides ECS-style iteration over entities by component composition.
#![allow(unused)]
fn main() {
// All riders with a position
for (id, rider, pos) in world.query::<(EntityId, &Rider, &Position)>().iter() {
// ...
}
// Entities with Position but without Route
for (id, pos) in world.query::<(EntityId, &Position)>()
.without::<Route>()
.iter()
{
// ...
}
// Extension components (cloned)
for (id, vip) in world.query::<(EntityId, &Ext<VipTag>)>().iter() {
// ...
}
}
| Type | Description |
|---|---|
QueryBuilder<Q> | Returned by world.query::<Q>(). Chain .with::<C>() / .without::<C>() filters, then .iter() |
ExtQueryMut<T> | Returned by world.query_ext_mut::<T>(). Call .for_each_mut(|id, val| ...) for mutable iteration |
Ext<T> | Query fetch marker for extension components (cloned) |
ExtMut<T> | Query fetch marker for mutable extension access |
With<C> | Filter: entity must have component C |
Without<C> | Filter: entity must not have component C |
ExtWith<T> | Filter: entity must have extension T |
ExtWithout<T> | Filter: entity must not have extension T |
Disabled Entities
| Method | Signature | Description |
|---|---|---|
disable | (&mut self, EntityId) | Mark an entity as disabled (skipped by systems) |
enable | (&mut self, EntityId) | Re-enable a disabled entity |
is_disabled | (&self, EntityId) -> bool | Check if an entity is disabled |
Dispatch
DispatchStrategy Trait
#![allow(unused)]
fn main() {
pub trait DispatchStrategy: Send + Sync {
fn decide(
&mut self,
elevator: EntityId,
elevator_position: f64,
group: &ElevatorGroup,
manifest: &DispatchManifest,
world: &World,
) -> DispatchDecision;
// Optional: batch decision for all idle elevators (default calls decide per elevator)
fn decide_all(...) -> Vec<(EntityId, DispatchDecision)>;
// Optional: cleanup when an elevator is removed (default no-op)
fn notify_removed(&mut self, elevator: EntityId);
}
}
DispatchDecision
| Variant | Description |
|---|---|
GoToStop(EntityId) | Send the elevator to the specified stop |
Idle | Remain idle |
DispatchManifest
Contains per-rider metadata grouped by stop. Passed to dispatch strategies each tick.
| Field / Method | Type | Description |
|---|---|---|
waiting_at_stop | BTreeMap<EntityId, Vec<RiderInfo>> | Riders waiting at each stop |
riding_to_stop | BTreeMap<EntityId, Vec<RiderInfo>> | Riders aboard elevators, grouped by destination |
waiting_count_at | (EntityId) -> usize | Number of riders waiting at a stop |
total_weight_at | (EntityId) -> f64 | Total weight of riders waiting at a stop |
riding_count_to | (EntityId) -> usize | Number of riders heading to a stop |
has_demand | (EntityId) -> bool | Whether a stop has any demand |
RiderInfo
Metadata about a single rider, available to dispatch strategies.
| Field | Type | Description |
|---|---|---|
id | EntityId | Rider entity ID |
destination | Option<EntityId> | Rider’s destination stop entity |
weight | f64 | Rider weight |
wait_ticks | u64 | Ticks this rider has been waiting |
ElevatorGroup
Runtime representation of a dispatch group containing one or more lines. The flat elevator_entities() and stop_entities() accessors are derived caches (union of all lines’ elevators/stops), rebuilt automatically via rebuild_caches().
| Getter | Return Type | Description |
|---|---|---|
id() | GroupId | Unique group identifier |
name() | &str | Human-readable group name |
lines() | &[LineInfo] | Lines belonging to this group |
elevator_entities() | &[EntityId] | Derived cache: all elevator entities across lines |
stop_entities() | &[EntityId] | Derived cache: all stop entities across lines |
LineInfo
Per-line relationship data within an ElevatorGroup. Denormalized cache maintained by Simulation; the source of truth for intrinsic line properties is the Line component in World.
| Getter | Return Type | Description |
|---|---|---|
entity() | EntityId | Line entity ID |
elevators() | &[EntityId] | Elevator entities on this line |
serves() | &[EntityId] | Stop entities served by this line |
Built-in Strategies
| Strategy | Constructor | Description |
|---|---|---|
ScanDispatch | ScanDispatch::new() | SCAN algorithm – sweeps end-to-end before reversing |
LookDispatch | LookDispatch::new() | LOOK algorithm – reverses at last request, not shaft end |
NearestCarDispatch | NearestCarDispatch::new() | Assigns each call to the closest idle elevator |
EtdDispatch | EtdDispatch::new() | Estimated Time to Destination – minimizes total cost |
BuiltinStrategy Enum
Serializable identifier for dispatch strategies (used in snapshots and configs).
| Variant | Description |
|---|---|
Scan | SCAN algorithm |
Look | LOOK algorithm |
NearestCar | Nearest-car algorithm |
Etd | Estimated Time to Destination |
Custom(String) | Custom strategy identified by name |
The instantiate() method creates a boxed DispatchStrategy from a variant (returns None for Custom).
Events
Events are emitted during tick execution and buffered for consumers. Drain them with sim.drain_events().
Elevator Events
| Variant | Fields | Description |
|---|---|---|
ElevatorDeparted | elevator: EntityId, from_stop: EntityId, tick: u64 | An elevator departed from a stop |
ElevatorArrived | elevator: EntityId, at_stop: EntityId, tick: u64 | An elevator arrived at a stop |
DoorOpened | elevator: EntityId, tick: u64 | Doors finished opening |
DoorClosed | elevator: EntityId, tick: u64 | Doors finished closing |
PassingFloor | elevator: EntityId, stop: EntityId, moving_up: bool, tick: u64 | Elevator passed a stop without stopping |
ElevatorIdle | elevator: EntityId, at_stop: Option<EntityId>, tick: u64 | Elevator became idle |
CapacityChanged | elevator: EntityId, current_load: OrderedFloat<f64>, capacity: OrderedFloat<f64>, tick: u64 | Elevator load changed after board or exit |
DirectionIndicatorChanged | elevator: EntityId, going_up: bool, going_down: bool, tick: u64 | Direction indicator lamps changed (dispatch-driven) |
Rider Events
| Variant | Fields | Description |
|---|---|---|
RiderSpawned | rider: EntityId, origin: EntityId, destination: EntityId, tick: u64 | A rider appeared at a stop |
RiderBoarded | rider: EntityId, elevator: EntityId, tick: u64 | A rider boarded an elevator |
RiderExited | rider: EntityId, elevator: EntityId, stop: EntityId, tick: u64 | A rider exited an elevator |
RiderRejected | rider: EntityId, elevator: EntityId, reason: RejectionReason, context: Option<RejectionContext>, tick: u64 | A rider was rejected from boarding |
RiderAbandoned | rider: EntityId, stop: EntityId, tick: u64 | A rider gave up waiting |
RiderEjected | rider: EntityId, elevator: EntityId, stop: EntityId, tick: u64 | A rider was ejected from a disabled/despawned elevator |
RiderRerouted | rider: EntityId, new_destination: EntityId, tick: u64 | A rider was manually rerouted |
Dispatch Events
| Variant | Fields | Description |
|---|---|---|
ElevatorAssigned | elevator: EntityId, stop: EntityId, tick: u64 | An elevator was assigned to serve a stop |
Topology Events
| Variant | Fields | Description |
|---|---|---|
StopAdded | stop: EntityId, line: EntityId, group: GroupId, tick: u64 | A new stop was added at runtime |
ElevatorAdded | elevator: EntityId, line: EntityId, group: GroupId, tick: u64 | A new elevator was added at runtime |
LineAdded | line: EntityId, group: GroupId, tick: u64 | A new line was added to the simulation |
LineRemoved | line: EntityId, group: GroupId, tick: u64 | A line was removed from the simulation |
LineReassigned | line: EntityId, old_group: GroupId, new_group: GroupId, tick: u64 | A line was reassigned to a different group |
ElevatorReassigned | elevator: EntityId, old_line: EntityId, new_line: EntityId, tick: u64 | An elevator was reassigned to a different line |
EntityDisabled | entity: EntityId, tick: u64 | An entity was disabled |
EntityEnabled | entity: EntityId, tick: u64 | An entity was re-enabled |
RouteInvalidated | rider: EntityId, affected_stop: EntityId, reason: RouteInvalidReason, tick: u64 | A rider’s route was invalidated by topology change |
RouteInvalidReason
| Variant | Description |
|---|---|
StopDisabled | A stop on the route was disabled |
NoAlternative | No alternative stop is available in the same group |
EventBus
Internal event bus used by the simulation. Games typically interact through sim.drain_events() instead.
| Method | Signature | Description |
|---|---|---|
emit | (&mut self, Event) | Push an event |
drain | (&mut self) -> Vec<Event> | Return and clear all pending events |
peek | (&self) -> &[Event] | View pending events without clearing |
EventChannel<T>
Typed event channel for game-specific events. Insert as a world resource.
| Method | Signature | Description |
|---|---|---|
new | () -> Self | Create an empty channel |
emit | (&mut self, T) | Emit an event |
drain | (&mut self) -> Vec<T> | Drain all pending events |
peek | (&self) -> &[T] | Peek at pending events |
is_empty | (&self) -> bool | Check if channel is empty |
len | (&self) -> usize | Number of pending events |
Metrics
Metrics (Global)
Aggregated simulation metrics, updated each tick. Query via sim.metrics().
| Getter | Return Type | Description |
|---|---|---|
avg_wait_time() | f64 | Average wait time in ticks (spawn to board) |
avg_ride_time() | f64 | Average ride time in ticks (board to exit) |
max_wait_time() | u64 | Maximum wait time observed (ticks) |
throughput() | u64 | Riders delivered in the current throughput window |
total_delivered() | u64 | Total riders delivered |
total_abandoned() | u64 | Total riders who abandoned |
total_spawned() | u64 | Total riders spawned |
abandonment_rate() | f64 | Abandonment rate (0.0 - 1.0) |
total_distance() | f64 | Total distance traveled by all elevators |
total_moves() | u64 | Total rounded-floor transitions across all elevators |
throughput_window_ticks() | u64 | Window size for throughput calculation (default: 3600) |
Builder method: Metrics::new().with_throughput_window(window_ticks).
TaggedMetric
Per-tag metric accumulator. Same core metrics as Metrics but scoped to entities sharing a tag. Query via sim.metrics_for_tag("zone:lobby").
| Getter | Return Type | Description |
|---|---|---|
avg_wait_time() | f64 | Average wait time for tagged riders |
total_delivered() | u64 | Total delivered with this tag |
total_abandoned() | u64 | Total abandoned with this tag |
total_spawned() | u64 | Total spawned with this tag |
max_wait_time() | u64 | Maximum wait time for tagged riders |
MetricTags
Tag storage and per-tag accumulators. Stored as a world resource.
| Method | Signature | Description |
|---|---|---|
tag | (&mut self, EntityId, impl Into<String>) | Attach a tag to an entity |
untag | (&mut self, EntityId, &str) | Remove a tag from an entity |
tags_for | (&self, EntityId) -> &[String] | Get all tags for an entity |
metric | (&self, &str) -> Option<&TaggedMetric> | Get the metric accumulator for a tag |
all_tags | (&self) -> impl Iterator<Item = &str> | Iterate all registered tags |
Configuration
All config types derive Serialize + Deserialize and are loadable from RON files.
SimConfig
| Field | Type | Description |
|---|---|---|
building | BuildingConfig | Building layout (stops) |
elevators | Vec<ElevatorConfig> | Elevator cars to install |
simulation | SimulationParams | Global timing parameters |
passenger_spawning | PassengerSpawnConfig | Spawning parameters (advisory, for game layer) |
BuildingConfig
| Field | Type | Description |
|---|---|---|
name | String | Human-readable building name |
stops | Vec<StopConfig> | Ordered list of stops (at least one required) |
StopConfig
| Field | Type | Description |
|---|---|---|
id | StopId | Unique stop identifier |
name | String | Human-readable stop name |
position | f64 | Absolute position along the shaft axis |
ElevatorConfig
| Field | Type | Description |
|---|---|---|
id | u32 | Numeric identifier (mapped to EntityId at init) |
name | String | Human-readable elevator name |
max_speed | f64 | Maximum travel speed (distance units/second) |
acceleration | f64 | Acceleration rate (distance units/second^2) |
deceleration | f64 | Deceleration rate (distance units/second^2) |
weight_capacity | f64 | Maximum weight the car can carry |
starting_stop | StopId | Stop where the elevator starts |
door_open_ticks | u32 | Ticks doors remain fully open |
door_transition_ticks | u32 | Ticks for a door open/close transition |
SimulationParams
| Field | Type | Description |
|---|---|---|
ticks_per_second | f64 | Simulation ticks per real-time second |
PassengerSpawnConfig
| Field | Type | Description |
|---|---|---|
mean_interval_ticks | u32 | Mean interval between spawns (for Poisson traffic generators) |
weight_range | (f64, f64) | (min, max) weight range for randomly spawned passengers |
LineConfig
| Field | Type | Description |
|---|---|---|
id | u32 | Unique line identifier (within the config) |
name | String | Human-readable name |
serves | Vec<StopId> | Stops served by this line |
elevators | Vec<ElevatorConfig> | Elevators on this line |
orientation | Orientation | Physical orientation (defaults to Vertical) |
position | Option<FloorPosition> | Optional floor-plan position |
min_position | Option<f64> | Lowest reachable position (auto-computed from stops if None) |
max_position | Option<f64> | Highest reachable position (auto-computed from stops if None) |
max_cars | Option<usize> | Max cars on this line (None = unlimited) |
GroupConfig
| Field | Type | Description |
|---|---|---|
id | u32 | Unique group identifier |
name | String | Human-readable name |
lines | Vec<u32> | Line IDs belonging to this group (references LineConfig::id) |
dispatch | BuiltinStrategy | Dispatch strategy for this group |
Components
Entity components are the data attached to simulation entities. Built-in components are managed by the simulation; games add custom data via extensions.
Component Types
| Component | Description |
|---|---|
Position | Position along the shaft axis (accessed via value() getter) |
Velocity | Velocity along the shaft axis, signed (accessed via value() getter) |
Elevator | Elevator car state and physics parameters |
Rider | Rider core data (weight, phase, origin, timing) |
Stop | Stop data (accessed via name() and position() getters) |
Route | Multi-leg route with legs: Vec<RouteLeg> and current_leg: usize |
Line | Physical path component (shaft, tether, track) |
Patience | Wait-limit tracking (max_wait_ticks: u64, waited_ticks: u64) |
Preferences | Boarding preferences (skip_full_elevator: bool, max_crowding_factor: f64) |
ElevatorPhase
| Variant | Description |
|---|---|
Idle | Parked with no pending requests |
MovingToStop(EntityId) | Travelling toward a specific stop |
DoorOpening | Doors are currently opening |
Loading | Doors open; riders may board or exit |
DoorClosing | Doors are currently closing |
Stopped | Stopped at a floor (doors closed, awaiting dispatch) |
Elevator Getters
| Getter | Return Type | Description |
|---|---|---|
phase() | ElevatorPhase | Current operational phase |
door() | &DoorState | Door finite-state machine |
max_speed() | f64 | Maximum travel speed |
acceleration() | f64 | Acceleration rate |
deceleration() | f64 | Deceleration rate |
weight_capacity() | f64 | Maximum weight capacity |
current_load() | f64 | Total weight currently aboard |
riders() | &[EntityId] | Entity IDs of riders aboard |
target_stop() | Option<EntityId> | Stop the car is heading toward |
door_transition_ticks() | u32 | Ticks for a door transition |
door_open_ticks() | u32 | Ticks the door stays open |
line() | EntityId | Line entity this car belongs to |
going_up() | bool | Up-direction indicator lamp (set by dispatch; both lamps lit when idle) |
going_down() | bool | Down-direction indicator lamp (set by dispatch; both lamps lit when idle) |
move_count() | u64 | Count of rounded-floor transitions (passing-floor crossings + arrivals) |
Line Getters
| Getter | Return Type | Description |
|---|---|---|
name() | &str | Human-readable name |
group() | GroupId | Dispatch group this line belongs to |
orientation() | Orientation | Physical orientation |
position() | Option<&FloorPosition> | Optional floor-plan position |
min_position() | f64 | Lowest reachable position along the line axis |
max_position() | f64 | Highest reachable position along the line axis |
max_cars() | Option<usize> | Maximum number of cars allowed on this line |
Direction
Direction of movement along a line axis.
| Variant | Description |
|---|---|
Up | Moving toward higher positions |
Down | Moving toward lower positions |
Method: reversed() returns the opposite direction.
RiderPhase
| Variant | Description |
|---|---|
Waiting | Waiting at a stop |
Boarding(EntityId) | Boarding an elevator (transient, one tick) |
Riding(EntityId) | Riding in an elevator |
Exiting(EntityId) | Exiting an elevator (transient, one tick) |
Walking | Walking between transfer stops |
Arrived | Reached final destination |
Abandoned | Gave up waiting |
Rider Getters
| Getter | Return Type | Description |
|---|---|---|
weight() | f64 | Weight contributed to elevator load |
phase() | RiderPhase | Current lifecycle phase |
current_stop() | Option<EntityId> | Stop the rider is at (while Waiting/Arrived/Abandoned) |
spawn_tick() | u64 | Tick when this rider was spawned |
board_tick() | Option<u64> | Tick when this rider boarded (for ride-time metrics) |
Route and RouteLeg
| Field / Method | Type | Description |
|---|---|---|
legs | Vec<RouteLeg> | Ordered legs of the route |
current_leg | usize | Index of the leg currently being traversed |
direct(from, to, GroupId) | -> Route | Create a single-leg route |
current() | -> Option<&RouteLeg> | Get the current leg |
advance() | -> bool | Advance to the next leg |
is_complete() | -> bool | Whether all legs have been completed |
current_destination() | -> Option<EntityId> | Destination of the current leg |
RouteLeg Fields
| Field | Type | Description |
|---|---|---|
from | EntityId | Origin stop entity |
to | EntityId | Destination stop entity |
via | TransportMode | How to travel this leg |
TransportMode
| Variant | Description |
|---|---|
Group(GroupId) | Use any elevator in the given dispatch group |
Line(EntityId) | Use a specific line (pinned routing) |
Walk | Walk between adjacent stops |
Identifiers
| Type | Wraps | Description |
|---|---|---|
EntityId | Generational SlotMap key | Universal entity identifier, used across all component storages. Allocated by world.spawn() |
StopId | StopId(u32) | Config-level stop identifier. Mapped to an EntityId at construction time via sim.stop_entity(StopId) |
GroupId | GroupId(u32) | Elevator group identifier. GroupId(0) is the default group |
Error Types
SimError
| Variant | Fields | Description |
|---|---|---|
InvalidConfig | field: &'static str, reason: String | Configuration is invalid |
EntityNotFound | EntityId | A referenced entity does not exist |
StopNotFound | StopId | A referenced stop ID does not exist |
GroupNotFound | GroupId | A referenced group does not exist |
InvalidState | entity: EntityId, reason: String | Operation attempted on entity in wrong state |
LineNotFound | EntityId | A referenced line entity does not exist |
NoRoute | origin: EntityId, destination: EntityId | No route exists between origin and destination across any group |
AmbiguousRoute | origin: EntityId, destination: EntityId | Multiple groups serve both origin and destination — caller must specify |
SimError implements std::error::Error and Display. It also has From<EntityId>, From<StopId>, and From<GroupId> conversions.
RejectionReason
| Variant | Description |
|---|---|
OverCapacity | Rider’s weight exceeds remaining elevator capacity |
PreferenceBased | Rider’s boarding preferences prevented boarding |
RejectionContext
Numeric details of a rejection. Fields use OrderedFloat<f64> (for Eq on the event).
| Field | Type | Description |
|---|---|---|
attempted_weight | OrderedFloat<f64> | Weight the rider attempted to add |
current_load | OrderedFloat<f64> | Current load on the elevator |
capacity | OrderedFloat<f64> | Maximum weight capacity |
Snapshots
WorldSnapshot
Serializable snapshot of the entire simulation state. Capture with sim.snapshot(), restore with snapshot.restore(custom_factory).
| Field | Type | Description |
|---|---|---|
tick | u64 | Simulation tick at capture time |
dt | f64 | Time delta per tick |
entities | Vec<EntitySnapshot> | All entity data |
groups | Vec<GroupSnapshot> | Elevator group data |
stop_lookup | HashMap<StopId, usize> | Stop ID to entity index mapping |
metrics | Metrics | Global metrics at capture time |
metric_tags | MetricTags | Per-tag metrics and entity-tag associations |
extensions | HashMap<String, HashMap<EntityId, String>> | Serialized extension data |
ticks_per_second | f64 | Tick rate for TimeAdapter reconstruction |
| Method | Signature | Description |
|---|---|---|
restore | (self, Option<&dyn Fn(&str) -> Option<Box<dyn DispatchStrategy>>>) -> Simulation | Restore a simulation from this snapshot |
Built-in strategies are auto-restored. For custom strategies, provide a factory function.
TimeAdapter
Converts between simulation ticks and wall-clock time. Access via sim.time().
| Method | Signature | Description |
|---|---|---|
new | (f64) -> Self | Create with the given tick rate |
ticks_to_seconds | (u64) -> f64 | Convert ticks to seconds |
seconds_to_ticks | (f64) -> u64 | Convert seconds to ticks (rounded) |
duration_to_ticks | (Duration) -> u64 | Convert a Duration to ticks (rounded) |
ticks_to_duration | (u64) -> Duration | Convert ticks to a Duration |
ticks_per_second | () -> f64 | The configured tick rate |
Phase
Simulation phase identifiers for hook registration.
| Variant | Description |
|---|---|
AdvanceTransient | Advance transient rider states (Boarding to Riding, Exiting to Arrived) |
Dispatch | Assign idle elevators to stops via dispatch strategy |
Movement | Update elevator position and velocity |
Doors | Tick door finite-state machines |
Loading | Board and exit riders |
Metrics | Aggregate metrics from tick events |
Traffic Generation
Available when the traffic feature is enabled (on by default). See the Traffic Generation chapter for a guided walkthrough.
TrafficPattern
Preset origin/destination distributions.
| Variant | Description |
|---|---|
Uniform | Equal probability for all pairs |
UpPeak | 80% from lobby, 20% inter-floor |
DownPeak | 80% to lobby, 20% inter-floor |
Lunchtime | 40% upper→mid, 40% mid→upper, 20% random |
Mixed | 30% up-peak, 30% down-peak, 40% inter-floor |
| Method | Signature | Description |
|---|---|---|
sample | (&self, &[EntityId], &mut impl Rng) -> Option<(EntityId, EntityId)> | Sample a pair from entity IDs |
sample_stop_ids | (&self, &[StopId], &mut impl Rng) -> Option<(StopId, StopId)> | Sample a pair from config stop IDs |
TrafficSchedule
Time-varying pattern selection.
| Method | Signature | Description |
|---|---|---|
new | (Vec<(Range<u64>, TrafficPattern)>) -> Self | Build from segment list |
with_fallback | (self, TrafficPattern) -> Self | Set the out-of-range fallback |
constant | (TrafficPattern) -> Self | Schedule with a single pattern for all ticks |
office_day | (ticks_per_hour: u64) -> Self | 9-hour office day preset |
pattern_at | (&self, u64) -> &TrafficPattern | Get the active pattern at a tick |
sample | (&self, u64, &[EntityId], &mut impl Rng) -> Option<(EntityId, EntityId)> | Sample using the active pattern |
sample_stop_ids | (&self, u64, &[StopId], &mut impl Rng) -> Option<(StopId, StopId)> | Same, by stop ID |
TrafficSource
Trait for external traffic generators.
pub trait TrafficSource {
fn generate(&mut self, tick: u64) -> Vec<SpawnRequest>;
}
SpawnRequest
pub struct SpawnRequest {
pub origin: StopId,
pub destination: StopId,
pub weight: f64,
}
PoissonSource
Poisson-arrival traffic generator.
| Method | Signature | Description |
|---|---|---|
new | (Vec<StopId>, TrafficSchedule, u32, (f64, f64)) -> Self | Build with stops, schedule, mean interval, weight range |
from_config | (&SimConfig) -> Self | Build from simulation config |
with_schedule | (self, TrafficSchedule) -> Self | Replace the schedule |
with_mean_interval | (self, u32) -> Self | Replace the mean arrival interval |
with_weight_range | (self, (f64, f64)) -> Self | Replace the weight range (min/max auto-swapped) |
generate | (&mut self, u64) -> Vec<SpawnRequest> | Generate spawn requests for a tick (from TrafficSource) |
Bevy Integration
The elevator-bevy crate is a Bevy 0.18 binary that wraps the core simulation with 2D rendering, a HUD, AI passengers, and keyboard controls. It serves as both a visual debugger for testing dispatch strategies and a reference implementation for integrating elevator-core into a game engine.
Running the Bevy app
With the default config:
cargo run
With a custom config:
cargo run -- assets/config/space_elevator.ron
The app reads a RON config file, creates a Simulation, and renders the building in a 2D view with elevator cars, rider dots, and a metrics HUD.
Plugin architecture
The integration is built around a single Bevy plugin:
pub struct ElevatorSimPlugin;
When you add this plugin to a Bevy app, it:
- Loads config from a RON file (CLI argument or
assets/config/default.ron) - Creates a
Simulationand inserts it as theSimulationResresource - Inserts
SimSpeedresource for controlling simulation speed - Registers the
EventWrappermessage for bridging sim events to Bevy - Adds systems for ticking the sim, rendering, AI passengers, input, and HUD
Key resources
SimulationRes
The core simulation is wrapped in a Bevy resource:
#[derive(Resource)]
pub struct SimulationRes {
pub sim: Simulation,
}
Any Bevy system can access the simulation through Res<SimulationRes> (read) or ResMut<SimulationRes> (write).
SimSpeed
Controls how many simulation ticks run per Bevy frame:
#[derive(Resource)]
pub struct SimSpeed {
pub multiplier: u32,
}
multiplier: 0– simulation is pausedmultiplier: 1– one tick per frame (normal speed)multiplier: 10– ten ticks per frame (fast forward)
The built-in input system maps keyboard keys to speed changes.
EventWrapper
Core simulation events are bridged into Bevy’s message system:
#[derive(Message)]
pub struct EventWrapper(pub Event);
Bevy systems can read simulation events using MessageReader<EventWrapper>:
fn my_system(mut events: MessageReader<EventWrapper>) {
for EventWrapper(event) in events.read() {
match event {
Event::RiderExited { rider, stop, tick, .. } => {
// React to rider arrival in Bevy-land.
}
_ => {}
}
}
}
The tick system
The bridge between elevator-core and Bevy is a single system that runs each frame:
pub fn tick_simulation(
mut sim: ResMut<SimulationRes>,
speed: Res<SimSpeed>,
mut events: MessageWriter<EventWrapper>,
) {
for _ in 0..speed.multiplier {
sim.sim.step();
}
for event in sim.sim.drain_events() {
events.write(EventWrapper(event));
}
}
It steps the simulation multiplier times, then drains all events and re-emits them as Bevy messages. This is the only point where the core simulation and Bevy synchronize.
Writing custom Bevy systems
To add your own gameplay systems that interact with the simulation, access SimulationRes:
use bevy::prelude::*;
use elevator_bevy::sim_bridge::SimulationRes;
use elevator_core::prelude::*;
fn print_metrics(sim: Res<SimulationRes>) {
let m = sim.sim.metrics();
if sim.sim.current_tick() % 3600 == 0 {
println!(
"Minute {}: delivered={} avg_wait={:.0}",
sim.sim.current_tick() / 3600,
m.total_delivered(),
m.avg_wait_time(),
);
}
}
Register your system in the Bevy app after the plugin:
app.add_plugins(ElevatorSimPlugin)
.add_systems(Update, print_metrics);
Module layout
The elevator-bevy crate is organized into focused modules:
| Module | Responsibility |
|---|---|
plugin.rs | ElevatorSimPlugin – loads config, creates sim, registers everything |
sim_bridge.rs | SimulationRes, SimSpeed, EventWrapper, tick system |
rendering.rs | 2D visualization of the building, elevators, and riders |
ui.rs | HUD overlay showing metrics and simulation state |
camera.rs | Camera setup sized to the building |
input.rs | Keyboard controls for speed adjustment |
passenger_ai.rs | Timer-based passenger spawning |
When to use elevator-bevy vs. building your own
Use elevator-bevy if you want a quick visual test of your dispatch strategy or config. Run it, watch the elevators move, tweak parameters.
Build your own if you are making a game. The Bevy crate is intentionally simple – it is a reference, not a framework. Copy the patterns you need (the SimulationRes resource, the tick system, the event bridge) into your own Bevy app and build your game systems around them.
The core library does not depend on Bevy at all. You can use it with any Rust game engine, a TUI, a web frontend via WASM, or pure headless batch simulation.
Non-Bevy Integration
elevator-core is engine-agnostic. The elevator-bevy crate is one reference integration; it’s the visual debugger ships in this repository. But nothing in elevator-core itself depends on Bevy — you can drop the library into macroquad, eframe/egui, a web backend, a CLI analysis tool, or a pure headless driver.
This chapter walks through the integration surface and shows three concrete patterns.
The integration contract
Integrating elevator-core into any host comes down to three things:
-
Build the
Simulationonce, up front.SimulationBuilder::new()orSimulationBuilder::from_config(config)→.build(). Keep theSimulationas state in your engine’s scene / app struct / actor. -
Drive the tick loop. Call
sim.step()each frame (or on a fixed-timestep accumulator, if you want to decouple sim rate from render rate).Simulation::step()is pure over world state — no I/O, no time-wall clock dependency, no engine-specific globals. -
Read state out, inject input in.
- Read state:
sim.world()returns aWorldyou can query viaquery::<(EntityId, &Rider, &Position)>()for rendering, or via typed accessors (world.elevator(id),world.stop_position(id)). - Inject input:
sim.spawn_rider_by_stop_id(origin, dest, weight),sim.push_destination(elev, stop),sim.reroute(rider, new_dest),sim.set_service_mode(elev, mode). - Change-event hook:
sim.drain_events()returns every event emitted during the last tick. Route them into toasts, particles, SFX, analytics.
- Read state:
That’s it. The entire public surface of the library is prelude + a handful of typed submodules; no engine extension points, no traits your app must implement.
Pattern 1 — Headless / CLI / web backend
The simplest integration. No rendering — you step the sim and consume events. Suitable for analysis tools, web backends streaming simulation state over Server-Sent Events, CI scenarios, or offline replay.
The repository ships examples/headless_trace.rs which is exactly this pattern:
cargo run --example headless_trace -- \
--config assets/config/default.ron \
--ticks 2000 \
--output /tmp/trace.ndjson
The body of the main loop is small enough to inline here:
for _ in 0..args.ticks {
sim.step();
for event in sim.drain_events() {
let line = serde_json::to_string(&event)?;
writeln!(out, "{line}")?;
}
}
Event implements Serialize / Deserialize, so consumers in any language can read the NDJSON stream. This is the integration shape a web backend would use: stream events over SSE / WebSocket, have a JS frontend render them.
Pattern 2 — macroquad (game loop)
macroquad is a lightweight cross-platform game framework with a simple async fn main game loop. The integration pattern is about 200 lines — most of the code is rendering, not elevator-core glue.
Advisory note. At the time of writing, macroquad 0.4.x carries RUSTSEC-2025-0035 (unsound mutable-static use; no fixed version available). We don’t ship a runnable example to keep this repository’s
cargo-denysupply-chain check green. The code sketch below is correct and will run if you addmacroquad = "0.4"as a dependency in your own crate.
The relevant integration pattern:
use elevator_core::components::{Elevator, RiderPhase, Stop};
use elevator_core::prelude::*;
use macroquad::prelude::*;
#[macroquad::main(window_conf)]
async fn main() {
let mut sim = build_sim(); // 1. Build once
loop {
if is_key_pressed(KeyCode::Space) {
spawn_random_rider(&mut sim); // 3b. Inject input
}
sim.step(); // 2. Drive the tick
let _events = sim.drain_events(); // 3c. Consume events
clear_background(BLACK);
draw_shaft(&sim); // 3a. Read state
draw_elevators(&sim);
draw_hud(&sim);
next_frame().await;
}
}
fn draw_elevators(sim: &Simulation) {
for (_, pos, car) in sim.world()
.query::<(EntityId, &Position, &Elevator)>()
.iter()
{
let y = position_to_screen_y(pos.value());
let color = if car.current_load() > 0.0 {
Color::from_rgba(100, 200, 255, 255)
} else {
Color::from_rgba(180, 180, 180, 255)
};
draw_rectangle(SHAFT_X + 4.0, y - 22.0, SHAFT_W - 8.0, 44.0, color);
}
}
The rendering functions pull component state via sim.world().query::<...>() — exactly the same API a Bevy system uses, just without Bevy’s dispatcher.
Decoupling sim rate from render rate
The example above steps the sim once per rendered frame. If you want the sim to run at a fixed 60 tick/sec regardless of display refresh rate:
let mut tick_accumulator = 0.0_f64;
let tick_interval = 1.0 / sim.time_adapter().ticks_per_second();
loop {
tick_accumulator += get_frame_time() as f64;
while tick_accumulator >= tick_interval {
sim.step();
tick_accumulator -= tick_interval;
}
// render once per frame regardless of tick count
render(&sim);
next_frame().await;
}
Pattern 3 — eframe / egui (immediate-mode UI)
eframe is the immediate-mode UI framework behind egui. It’s suited for inspector-style tools — a dashboard on the sim rather than a game. We don’t ship a runnable eframe example (eframe transitively pulls in wgpu, which is a heavy dep for an example), but the pattern is:
// Your app holds the sim as state.
struct ElevatorApp {
sim: Simulation,
tick_per_frame: bool,
}
impl eframe::App for ElevatorApp {
fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
// 2. Drive the tick. Requesting continuous repaint keeps the
// UI live; otherwise the sim only advances on user interaction.
if self.tick_per_frame {
self.sim.step();
let _events = self.sim.drain_events();
ctx.request_repaint();
}
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("elevator-core inspector");
// 3a. Read state — same queries as macroquad, just rendering
// with egui widgets instead of rectangles.
for (_, pos, car) in self.sim.world()
.query::<(EntityId, &Position, &Elevator)>()
.iter()
{
ui.label(format!(
"elev {:?}: pos={:.1} load={} phase={:?}",
car.line(), pos.value(), car.current_load(), car.phase()
));
}
// 3b. Input via egui buttons.
if ui.button("spawn rider 0→3").clicked() {
let _ = self.sim.spawn_rider_by_stop_id(StopId(0), StopId(3), 72.0);
}
});
}
}
fn main() -> eframe::Result<()> {
eframe::run_native(
"elevator-core",
eframe::NativeOptions::default(),
Box::new(|_| Ok(Box::new(ElevatorApp {
sim: build_sim(),
tick_per_frame: true,
}))),
)
}
Everything above except the eframe::App trait impl is standard elevator-core usage. The only engine-specific concept is ctx.request_repaint() to keep the UI ticking when the sim is running.
Picking a pattern
| Host | When it’s the right fit |
|---|---|
| headless / CLI | Analysis, batch runs, CI, web backends (stream Events over SSE / WebSocket to a JS frontend). |
| macroquad | 2D games, rapid iteration, WASM browser builds. Minimal dep footprint. |
| eframe / egui | Dashboards, inspectors, debuggers. Good when you want live-editable sim state with sliders + buttons. |
| Bevy | Full 3D games, ECS-native integration, complex scene systems. See Bevy Integration. |
| Wasm-in-browser | Any of the above, as long as the traffic feature (which uses rand::rngs::ThreadRng by default) is either disabled or paired with a seeded StdRng via PoissonSource::with_rng. |
Determinism across hosts
elevator-core is deterministic: same config + same sequence of inputs produce identical event streams across hosts. If your renderer needs to replay a saved scenario, combine WorldSnapshot::restore() (from Snapshots and Determinism) with a seeded StdRng on any PoissonSource — the tick loop itself has no internal randomness.
Next steps
- Snapshots and Determinism — round-trip save/load so integrations can persist simulation state.
- Metrics and Events — the
Eventenum and metric accumulators that drive UI updates. - Performance — throughput baselines and scaling guidance for choosing a tick rate.