Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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_second says 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.md in the repo.

Project structure

The repository is a Cargo workspace with two crates:

CratePurpose
elevator-coreThe simulation library. Pure Rust, no engine dependencies. This is what you add to your project.
elevator-bevyA 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.

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:

GroupItems
Builder & simSimulationBuilder, Simulation, RiderBuilder
ComponentsRider, RiderPhase, Elevator, ElevatorPhase, Stop, Line, Position, Velocity, FloorPosition, Route, Patience, Preferences, AccessControl, Orientation, ServiceMode
ConfigSimConfig, GroupConfig, LineConfig
Dispatch traitsDispatchStrategy, RepositionStrategy
Reposition strategiesNearestIdle, ReturnToLobby, SpreadEvenly, DemandWeighted
IdentityEntityId, StopId, GroupId
Errors & eventsSimError, RejectionReason, RejectionContext, Event, EventBus
MiscMetrics, 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

FlagDefault?Enables
trafficyestraffic module: PoissonSource, TrafficPattern, TrafficSchedule. Pulls in rand.
energynoPer-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?

  1. The builder created a Simulation containing a World with three stop entities and one elevator entity, plus a SCAN dispatch strategy.
  2. spawn_rider_by_stop_id created a rider entity at the Lobby with a route to Floor 3.
  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.
  4. 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:

TypeWhat it identifiesWhen you use it
EntityIdAny entity at runtime (stop, elevator, rider)Event payloads, world lookups, dispatch decisions
StopIdA stop in the config (e.g., StopId(0))Builder API, config files, spawn_rider_by_stop_id
GroupIdAn 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 / LineReassigned events 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 f64 values. 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 at 0.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) (uses ticks_per_second from 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
PhaseWhere is the rider?What triggers the transition?
WaitingAt a stop, in the queueElevator arrives, doors open, loading phase boards them
BoardingBeing loaded into the elevatorAdvance Transient phase (next tick)
RidingInside the elevatorElevator arrives at destination, doors open, loading phase exits them
ExitingExiting the elevatorAdvance Transient phase (next tick)
ArrivedReached final destinationConsumer decides: settle (-> Resident), despawn, or leave
AbandonedLeft the stopPatience ran out; consumer can settle or despawn
ResidentParked at a stop, not seeking an elevatorConsumer 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 stop
  • sim.waiting_at(stop) – riders waiting for an elevator at a stop
  • sim.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 an Elevator component
  • sim.is_rider(id) — the entity has a Rider component
  • sim.is_stop(id) — the entity has a Stop component

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 Resident
  • sim.reroute_rider(id, route) – sends a Resident rider back to Waiting with a new route
  • sim.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:

PhaseMeaning
IdleNo target, waiting for dispatch to assign a stop
MovingToStop(EntityId)Traveling toward a target stop
DoorOpeningDoors are currently opening
LoadingDoors open; riders may board or exit
DoorClosingDoors are currently closing
StoppedAt a floor, doors closed, awaiting dispatch

An elevator arriving at a stop cycles through DoorOpeningLoadingDoorClosingStopped. 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_upgoing_downMeaning
truetrueIdle — the car will accept riders in either direction
truefalseCommitted to an upward trip
falsetrueCommitted to a downward trip

The lamps are auto-managed by the dispatch phase:

  • On DispatchDecision::GoToStop(target), the car’s indicators are set from target vs. current position.
  • On DispatchDecision::Idle the pair resets to (true, true).
  • A DirectionIndicatorChanged event 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:

  1. Builds a DispatchManifest containing per-stop demand (waiting riders, their weights, their wait times) and per-destination riding riders.
  2. Collects all idle elevators in each group along with their current positions.
  3. Calls the group’s DispatchStrategy with this information.
  4. Applies the returned DispatchDecision for each elevator – either GoToStop(entity_id) to assign a target, or Idle to 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

StrategyAlgorithmBest forTrade-off
ScanDispatchSweep end-to-end, reversing at shaft extremesSingle elevator, uniform trafficSimple and fair, but wastes time traveling past the last request
LookDispatchLike SCAN, but reverses at the last request in the current directionSingle elevator, sparse trafficMore efficient than SCAN when requests cluster, slightly less predictable
NearestCarDispatchAssign each call to the closest idle elevatorMulti-elevator groupsLow average wait, but can cause bunching when elevators cluster
EtdDispatchMinimize estimated time to destination across all ridersMulti-elevator groups with mixed trafficBest 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_weight to 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:

MethodReturnsDescription
waiting_count_at(stop)usizeNumber of riders waiting at a stop
total_weight_at(stop)f64Total weight of riders waiting at a stop
has_demand(stop)boolWhether a stop has any demand (waiting or riding-to)
riding_count_to(stop)usizeNumber 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 GoToStop vs. Idle.
  • DestinationQueue management happens in the AdvanceQueue phase — 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:

  1. Simulation::remove_elevator(id) is called, OR
  2. Simulation::reassign_elevator_to_line(id, new_line) moves an elevator across groups (same-group moves don’t fire notify_removed because 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_all run 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 DispatchManifest is immutable — never try to mutate demand from inside decide. 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. Use BTreeMap or 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?

NeedUse
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/loadExtension (register by name; resources are not snapshotted)
Quick scratchpad you can wipe between ticksResource
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 other Simulation-level methods while a tick is in flight — the simulation is borrowed.
  • Spawning during a hook: use world.spawn() + direct component inserts, or queue SpawnRequests into a resource and drain them after sim.step() returns. The before(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

PhaseWhen hooks run
Phase::AdvanceTransientBefore/after transitional states advance
Phase::DispatchBefore/after elevator assignment
Phase::RepositionBefore/after idle-elevator repositioning (no-op if no reposition strategy configured)
Phase::MovementBefore/after position updates
Phase::DoorsBefore/after door state machine ticks
Phase::LoadingBefore/after boarding and exiting
Phase::MetricsBefore/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

FieldTypeDescriptionDefault
idu32Unique numeric ID within the config (mapped to EntityId at runtime)
nameStringHuman-readable name for UIs and logs
max_speedf64Maximum travel speed (distance units/second)2.0
accelerationf64Acceleration rate (distance units/second^2)1.5
decelerationf64Deceleration rate (distance units/second^2)2.0
weight_capacityf64Maximum total rider weight800.0
starting_stopStopIdWhere this elevator starts
door_open_ticksu32Ticks doors stay fully open10
door_transition_ticksu32Ticks for a door open/close transition5

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),
),
FieldMeaning
mean_interval_ticksAverage 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 StopId values
  • At least one elevator
  • All physics parameters positive
  • Each elevator’s starting_stop references 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:

PatternDistribution
UniformEqual probability for all origin/destination pairs
UpPeak80% from lobby, 20% inter-floor (morning rush)
DownPeak80% to lobby, 20% inter-floor (evening rush)
Lunchtime40% upper→mid, 40% mid→upper, 20% random
Mixed30% 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 rush
  • TrafficSchedule::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:

EventWhen 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:

EventWhen 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:

EventWhen 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 tick field, 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 RiderBoarded always precedes elevator 1’s” across library versions.
  • Pair invariants: RiderBoarded always precedes the matching RiderExited for the same rider; DoorOpened always precedes DoorClosed for 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

MetricDescription
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:

MethodReturns
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:

  1. The same initial SimConfig (same stops, elevators, groups, lines, dispatch strategy).
  2. The same sequence of API calls (spawn_rider, despawn_rider, tag_entity, hook mutations, etc.).
  3. 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:

  • PoissonSource and 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

  1. Serialize the initial config.
  2. Log every external mutation (spawn_rider, despawn_rider, tag changes) with its tick.
  3. 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():

PhaseCostNotes
Advance transientO(R) worst-case, O(transitioning riders) typicalOnly 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.
RepositionO(E · S)Only runs if configured.
MovementO(E)Pure arithmetic per elevator.
DoorsO(E)Door FSM per elevator.
LoadingO(boarding + exiting at each open door)Uses the rider index for per-stop queues.
MetricsO(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):

EntityBytes (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/:

BenchMeasures
sim_benchEnd-to-end step() across representative scenarios
scaling_benchThroughput vs. rider count
dispatch_benchPer-strategy cost at a fixed rider load
multi_line_benchMulti-group, multi-line buildings
query_benchPopulation 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

ItemTime
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)

ScenarioTime per runPer 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:

ScaleSCANLOOKNearestCarETD
5e, 10s61 µs67 µs63 µs66 µs
20e, 50s436 µs395 µs423 µs413 µs
50e, 200s2.18 ms2.00 ms2.04 ms1.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:

Query1001 00010 000
query<Rider>13 µs60 µs744 µs
query_tuple<&Rider, &Patience>12 µs52 µs859 µs
query_elevators (10/50/200)4 µs5 µs13 µ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)

ScenarioTime
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:

  1. Pick the cheapest dispatch strategy that meets your needs. NearestCarDispatch is usually a better default than ETD at scale.
  2. Split into groups. Each group dispatches independently; two groups of 20 elevators each is cheaper than one group of 40.
  3. Drain events every tick (or redirect into a bounded ring buffer) to keep memory flat.
  4. 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.
  5. 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.

CategoryItems
Builder & simSimulationBuilder, Simulation, RiderBuilder
ComponentsRider, RiderPhase, Elevator, ElevatorPhase, Stop, Line, Position, Velocity, FloorPosition, Route, Patience, Preferences, AccessControl, Orientation, ServiceMode
ConfigSimConfig, GroupConfig, LineConfig
Dispatch traitsDispatchStrategy, RepositionStrategy
Reposition strategiesNearestIdle, ReturnToLobby, SpreadEvenly, DemandWeighted
IdentityEntityId, StopId, GroupId
Errors & eventsSimError, RejectionReason, RejectionContext, Event, EventBus
MiscMetrics, TimeAdapter

Not in the prelude (import explicitly):

  • elevator_core::dispatch::scan::ScanDispatch, dispatch::look::LookDispatch, dispatch::nearest_car::NearestCarDispatch, dispatch::etd::EtdDispatch
  • elevator_core::config::{ElevatorConfig, StopConfig}
  • elevator_core::traffic::* (feature-gated behind traffic)
  • elevator_core::snapshot::WorldSnapshot
  • elevator_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().

MethodSignatureDescription
new() -> SelfCreate a builder with minimal defaults
from_config(SimConfig) -> SelfCreate a builder from an existing config
stops(Vec<StopConfig>) -> SelfReplace all stops
stop(StopId, impl Into<String>, f64) -> SelfAdd a single stop
elevators(Vec<ElevatorConfig>) -> SelfReplace all elevators
elevator(ElevatorConfig) -> SelfAdd a single elevator
ticks_per_second(f64) -> SelfSet the tick rate
building_name(impl Into<String>) -> SelfSet the building name
dispatch(impl DispatchStrategy) -> SelfSet dispatch for the default group
dispatch_for_group(GroupId, impl DispatchStrategy) -> SelfSet dispatch for a specific group
before(Phase, impl Fn(&mut World)) -> SelfRegister a before-phase hook
after(Phase, impl Fn(&mut World)) -> SelfRegister an after-phase hook
before_group(Phase, GroupId, impl Fn(&mut World)) -> SelfBefore-phase hook for a specific group
after_group(Phase, GroupId, impl Fn(&mut World)) -> SelfAfter-phase hook for a specific group
line(LineConfig) -> SelfAdd a single line configuration (switches to explicit topology mode)
lines(Vec<LineConfig>) -> SelfReplace all lines
group(GroupConfig) -> SelfAdd a single group configuration
groups(Vec<GroupConfig>) -> SelfReplace all groups
with_ext::<T>(&str) -> SelfPre-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

MethodSignatureDescription
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) -> PhaseContextBuild the tick/dt context for the current tick

Rider Management

MethodSignatureDescription
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

MethodSignatureDescription
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 EventBusGet a mutable reference to the internal event bus

Metrics and Tagging

MethodSignatureDescription
metrics(&self) -> &MetricsGet current aggregate metrics
metrics_mut(&mut self) -> &mut MetricsGet 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

MethodSignatureDescription
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) -> GroupIdCreate 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) -> boolCheck if an entity is disabled

Topology Queries

MethodSignatureDescription
all_lines(&self) -> Vec<EntityId>All line entities in the simulation
line_count(&self) -> usizeNumber 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

MethodSignatureDescription
world(&self) -> &WorldShared reference to the ECS world
world_mut(&mut self) -> &mut WorldMutable reference to the ECS world
current_tick(&self) -> u64Current simulation tick
dt(&self) -> f64Time 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) -> &TimeAdapterTick-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

MethodSignatureDescription
is_elevator(&self, EntityId) -> boolCheck if an entity has an Elevator component
is_rider(&self, EntityId) -> boolCheck if an entity has a Rider component
is_stop(&self, EntityId) -> boolCheck if an entity has a Stop component
is_disabled(&self, EntityId) -> boolCheck if an entity is disabled
idle_elevator_count(&self) -> usizeCount of elevators in Idle phase (excludes disabled)
elevators_in_phase(&self, ElevatorPhase) -> usizeCount 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

MethodSignatureDescription
set_dispatch(&mut self, GroupId, Box<dyn DispatchStrategy>, BuiltinStrategy)Replace the dispatch strategy for a group

Hooks (Post-Build)

MethodSignatureDescription
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

MethodSignatureDescription
snapshot(&self) -> WorldSnapshotCreate 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.

FieldTypeDefaultDescription
max_speedf642.0Maximum travel speed
accelerationf641.5Acceleration rate
decelerationf642.0Deceleration rate
weight_capacityf64800.0Maximum weight the car can carry
door_transition_ticksu325Ticks for a door open/close transition
door_open_ticksu3210Ticks the door stays fully open

LineParams

Parameters for add_line at runtime. All fields are public.

FieldTypeDescription
nameStringHuman-readable name
groupGroupIdDispatch group to add this line to
orientationOrientationPhysical orientation (defaults to Vertical)
min_positionf64Lowest reachable position on the line axis
max_positionf64Highest reachable position on the line axis
positionOption<FloorPosition>Optional floor-plan position
max_carsOption<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

MethodSignatureDescription
new() -> SelfCreate an empty world
spawn(&mut self) -> EntityIdAllocate a new entity (no components attached)
despawn(&mut self, EntityId)Remove an entity and all its components
is_alive(&self, EntityId) -> boolCheck if an entity is alive
entity_count(&self) -> usizeNumber of live entities

Component Accessors

Each built-in component has a getter, mutable getter, and setter. The pattern is the same for all:

ComponentGetGet MutSet
Positionposition(EntityId) -> Option<&Position>position_mut(EntityId)set_position(EntityId, Position)
Velocityvelocity(EntityId) -> Option<&Velocity>velocity_mut(EntityId)set_velocity(EntityId, Velocity)
Elevatorelevator(EntityId) -> Option<&Elevator>elevator_mut(EntityId)set_elevator(EntityId, Elevator)
Riderrider(EntityId) -> Option<&Rider>rider_mut(EntityId)set_rider(EntityId, Rider)
Stopstop(EntityId) -> Option<&Stop>stop_mut(EntityId)set_stop(EntityId, Stop)
Routeroute(EntityId) -> Option<&Route>route_mut(EntityId)set_route(EntityId, Route)
Lineline(EntityId) -> Option<&Line>line_mut(EntityId)set_line(EntityId, Line)
Patiencepatience(EntityId) -> Option<&Patience>patience_mut(EntityId)set_patience(EntityId, Patience)
Preferencespreferences(EntityId) -> Option<&Preferences>set_preferences(EntityId, Preferences)

Iteration Helpers

MethodSignatureDescription
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

MethodSignatureDescription
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.

MethodSignatureDescription
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.

MethodSignatureDescription
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() {
    // ...
}
}
TypeDescription
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

MethodSignatureDescription
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) -> boolCheck 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

VariantDescription
GoToStop(EntityId)Send the elevator to the specified stop
IdleRemain idle

DispatchManifest

Contains per-rider metadata grouped by stop. Passed to dispatch strategies each tick.

Field / MethodTypeDescription
waiting_at_stopBTreeMap<EntityId, Vec<RiderInfo>>Riders waiting at each stop
riding_to_stopBTreeMap<EntityId, Vec<RiderInfo>>Riders aboard elevators, grouped by destination
waiting_count_at(EntityId) -> usizeNumber of riders waiting at a stop
total_weight_at(EntityId) -> f64Total weight of riders waiting at a stop
riding_count_to(EntityId) -> usizeNumber of riders heading to a stop
has_demand(EntityId) -> boolWhether a stop has any demand

RiderInfo

Metadata about a single rider, available to dispatch strategies.

FieldTypeDescription
idEntityIdRider entity ID
destinationOption<EntityId>Rider’s destination stop entity
weightf64Rider weight
wait_ticksu64Ticks 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().

GetterReturn TypeDescription
id()GroupIdUnique group identifier
name()&strHuman-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.

GetterReturn TypeDescription
entity()EntityIdLine entity ID
elevators()&[EntityId]Elevator entities on this line
serves()&[EntityId]Stop entities served by this line

Built-in Strategies

StrategyConstructorDescription
ScanDispatchScanDispatch::new()SCAN algorithm – sweeps end-to-end before reversing
LookDispatchLookDispatch::new()LOOK algorithm – reverses at last request, not shaft end
NearestCarDispatchNearestCarDispatch::new()Assigns each call to the closest idle elevator
EtdDispatchEtdDispatch::new()Estimated Time to Destination – minimizes total cost

BuiltinStrategy Enum

Serializable identifier for dispatch strategies (used in snapshots and configs).

VariantDescription
ScanSCAN algorithm
LookLOOK algorithm
NearestCarNearest-car algorithm
EtdEstimated 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

VariantFieldsDescription
ElevatorDepartedelevator: EntityId, from_stop: EntityId, tick: u64An elevator departed from a stop
ElevatorArrivedelevator: EntityId, at_stop: EntityId, tick: u64An elevator arrived at a stop
DoorOpenedelevator: EntityId, tick: u64Doors finished opening
DoorClosedelevator: EntityId, tick: u64Doors finished closing
PassingFloorelevator: EntityId, stop: EntityId, moving_up: bool, tick: u64Elevator passed a stop without stopping
ElevatorIdleelevator: EntityId, at_stop: Option<EntityId>, tick: u64Elevator became idle
CapacityChangedelevator: EntityId, current_load: OrderedFloat<f64>, capacity: OrderedFloat<f64>, tick: u64Elevator load changed after board or exit
DirectionIndicatorChangedelevator: EntityId, going_up: bool, going_down: bool, tick: u64Direction indicator lamps changed (dispatch-driven)

Rider Events

VariantFieldsDescription
RiderSpawnedrider: EntityId, origin: EntityId, destination: EntityId, tick: u64A rider appeared at a stop
RiderBoardedrider: EntityId, elevator: EntityId, tick: u64A rider boarded an elevator
RiderExitedrider: EntityId, elevator: EntityId, stop: EntityId, tick: u64A rider exited an elevator
RiderRejectedrider: EntityId, elevator: EntityId, reason: RejectionReason, context: Option<RejectionContext>, tick: u64A rider was rejected from boarding
RiderAbandonedrider: EntityId, stop: EntityId, tick: u64A rider gave up waiting
RiderEjectedrider: EntityId, elevator: EntityId, stop: EntityId, tick: u64A rider was ejected from a disabled/despawned elevator
RiderReroutedrider: EntityId, new_destination: EntityId, tick: u64A rider was manually rerouted

Dispatch Events

VariantFieldsDescription
ElevatorAssignedelevator: EntityId, stop: EntityId, tick: u64An elevator was assigned to serve a stop

Topology Events

VariantFieldsDescription
StopAddedstop: EntityId, line: EntityId, group: GroupId, tick: u64A new stop was added at runtime
ElevatorAddedelevator: EntityId, line: EntityId, group: GroupId, tick: u64A new elevator was added at runtime
LineAddedline: EntityId, group: GroupId, tick: u64A new line was added to the simulation
LineRemovedline: EntityId, group: GroupId, tick: u64A line was removed from the simulation
LineReassignedline: EntityId, old_group: GroupId, new_group: GroupId, tick: u64A line was reassigned to a different group
ElevatorReassignedelevator: EntityId, old_line: EntityId, new_line: EntityId, tick: u64An elevator was reassigned to a different line
EntityDisabledentity: EntityId, tick: u64An entity was disabled
EntityEnabledentity: EntityId, tick: u64An entity was re-enabled
RouteInvalidatedrider: EntityId, affected_stop: EntityId, reason: RouteInvalidReason, tick: u64A rider’s route was invalidated by topology change

RouteInvalidReason

VariantDescription
StopDisabledA stop on the route was disabled
NoAlternativeNo alternative stop is available in the same group

EventBus

Internal event bus used by the simulation. Games typically interact through sim.drain_events() instead.

MethodSignatureDescription
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.

MethodSignatureDescription
new() -> SelfCreate 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) -> boolCheck if channel is empty
len(&self) -> usizeNumber of pending events

Metrics

Metrics (Global)

Aggregated simulation metrics, updated each tick. Query via sim.metrics().

GetterReturn TypeDescription
avg_wait_time()f64Average wait time in ticks (spawn to board)
avg_ride_time()f64Average ride time in ticks (board to exit)
max_wait_time()u64Maximum wait time observed (ticks)
throughput()u64Riders delivered in the current throughput window
total_delivered()u64Total riders delivered
total_abandoned()u64Total riders who abandoned
total_spawned()u64Total riders spawned
abandonment_rate()f64Abandonment rate (0.0 - 1.0)
total_distance()f64Total distance traveled by all elevators
total_moves()u64Total rounded-floor transitions across all elevators
throughput_window_ticks()u64Window 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").

GetterReturn TypeDescription
avg_wait_time()f64Average wait time for tagged riders
total_delivered()u64Total delivered with this tag
total_abandoned()u64Total abandoned with this tag
total_spawned()u64Total spawned with this tag
max_wait_time()u64Maximum wait time for tagged riders

MetricTags

Tag storage and per-tag accumulators. Stored as a world resource.

MethodSignatureDescription
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

FieldTypeDescription
buildingBuildingConfigBuilding layout (stops)
elevatorsVec<ElevatorConfig>Elevator cars to install
simulationSimulationParamsGlobal timing parameters
passenger_spawningPassengerSpawnConfigSpawning parameters (advisory, for game layer)

BuildingConfig

FieldTypeDescription
nameStringHuman-readable building name
stopsVec<StopConfig>Ordered list of stops (at least one required)

StopConfig

FieldTypeDescription
idStopIdUnique stop identifier
nameStringHuman-readable stop name
positionf64Absolute position along the shaft axis

ElevatorConfig

FieldTypeDescription
idu32Numeric identifier (mapped to EntityId at init)
nameStringHuman-readable elevator name
max_speedf64Maximum travel speed (distance units/second)
accelerationf64Acceleration rate (distance units/second^2)
decelerationf64Deceleration rate (distance units/second^2)
weight_capacityf64Maximum weight the car can carry
starting_stopStopIdStop where the elevator starts
door_open_ticksu32Ticks doors remain fully open
door_transition_ticksu32Ticks for a door open/close transition

SimulationParams

FieldTypeDescription
ticks_per_secondf64Simulation ticks per real-time second

PassengerSpawnConfig

FieldTypeDescription
mean_interval_ticksu32Mean interval between spawns (for Poisson traffic generators)
weight_range(f64, f64)(min, max) weight range for randomly spawned passengers

LineConfig

FieldTypeDescription
idu32Unique line identifier (within the config)
nameStringHuman-readable name
servesVec<StopId>Stops served by this line
elevatorsVec<ElevatorConfig>Elevators on this line
orientationOrientationPhysical orientation (defaults to Vertical)
positionOption<FloorPosition>Optional floor-plan position
min_positionOption<f64>Lowest reachable position (auto-computed from stops if None)
max_positionOption<f64>Highest reachable position (auto-computed from stops if None)
max_carsOption<usize>Max cars on this line (None = unlimited)

GroupConfig

FieldTypeDescription
idu32Unique group identifier
nameStringHuman-readable name
linesVec<u32>Line IDs belonging to this group (references LineConfig::id)
dispatchBuiltinStrategyDispatch 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

ComponentDescription
PositionPosition along the shaft axis (accessed via value() getter)
VelocityVelocity along the shaft axis, signed (accessed via value() getter)
ElevatorElevator car state and physics parameters
RiderRider core data (weight, phase, origin, timing)
StopStop data (accessed via name() and position() getters)
RouteMulti-leg route with legs: Vec<RouteLeg> and current_leg: usize
LinePhysical path component (shaft, tether, track)
PatienceWait-limit tracking (max_wait_ticks: u64, waited_ticks: u64)
PreferencesBoarding preferences (skip_full_elevator: bool, max_crowding_factor: f64)

ElevatorPhase

VariantDescription
IdleParked with no pending requests
MovingToStop(EntityId)Travelling toward a specific stop
DoorOpeningDoors are currently opening
LoadingDoors open; riders may board or exit
DoorClosingDoors are currently closing
StoppedStopped at a floor (doors closed, awaiting dispatch)

Elevator Getters

GetterReturn TypeDescription
phase()ElevatorPhaseCurrent operational phase
door()&DoorStateDoor finite-state machine
max_speed()f64Maximum travel speed
acceleration()f64Acceleration rate
deceleration()f64Deceleration rate
weight_capacity()f64Maximum weight capacity
current_load()f64Total weight currently aboard
riders()&[EntityId]Entity IDs of riders aboard
target_stop()Option<EntityId>Stop the car is heading toward
door_transition_ticks()u32Ticks for a door transition
door_open_ticks()u32Ticks the door stays open
line()EntityIdLine entity this car belongs to
going_up()boolUp-direction indicator lamp (set by dispatch; both lamps lit when idle)
going_down()boolDown-direction indicator lamp (set by dispatch; both lamps lit when idle)
move_count()u64Count of rounded-floor transitions (passing-floor crossings + arrivals)

Line Getters

GetterReturn TypeDescription
name()&strHuman-readable name
group()GroupIdDispatch group this line belongs to
orientation()OrientationPhysical orientation
position()Option<&FloorPosition>Optional floor-plan position
min_position()f64Lowest reachable position along the line axis
max_position()f64Highest 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.

VariantDescription
UpMoving toward higher positions
DownMoving toward lower positions

Method: reversed() returns the opposite direction.

RiderPhase

VariantDescription
WaitingWaiting 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)
WalkingWalking between transfer stops
ArrivedReached final destination
AbandonedGave up waiting

Rider Getters

GetterReturn TypeDescription
weight()f64Weight contributed to elevator load
phase()RiderPhaseCurrent lifecycle phase
current_stop()Option<EntityId>Stop the rider is at (while Waiting/Arrived/Abandoned)
spawn_tick()u64Tick when this rider was spawned
board_tick()Option<u64>Tick when this rider boarded (for ride-time metrics)

Route and RouteLeg

Field / MethodTypeDescription
legsVec<RouteLeg>Ordered legs of the route
current_legusizeIndex of the leg currently being traversed
direct(from, to, GroupId)-> RouteCreate a single-leg route
current()-> Option<&RouteLeg>Get the current leg
advance()-> boolAdvance to the next leg
is_complete()-> boolWhether all legs have been completed
current_destination()-> Option<EntityId>Destination of the current leg

RouteLeg Fields

FieldTypeDescription
fromEntityIdOrigin stop entity
toEntityIdDestination stop entity
viaTransportModeHow to travel this leg

TransportMode

VariantDescription
Group(GroupId)Use any elevator in the given dispatch group
Line(EntityId)Use a specific line (pinned routing)
WalkWalk between adjacent stops

Identifiers

TypeWrapsDescription
EntityIdGenerational SlotMap keyUniversal entity identifier, used across all component storages. Allocated by world.spawn()
StopIdStopId(u32)Config-level stop identifier. Mapped to an EntityId at construction time via sim.stop_entity(StopId)
GroupIdGroupId(u32)Elevator group identifier. GroupId(0) is the default group

Error Types

SimError

VariantFieldsDescription
InvalidConfigfield: &'static str, reason: StringConfiguration is invalid
EntityNotFoundEntityIdA referenced entity does not exist
StopNotFoundStopIdA referenced stop ID does not exist
GroupNotFoundGroupIdA referenced group does not exist
InvalidStateentity: EntityId, reason: StringOperation attempted on entity in wrong state
LineNotFoundEntityIdA referenced line entity does not exist
NoRouteorigin: EntityId, destination: EntityIdNo route exists between origin and destination across any group
AmbiguousRouteorigin: EntityId, destination: EntityIdMultiple 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

VariantDescription
OverCapacityRider’s weight exceeds remaining elevator capacity
PreferenceBasedRider’s boarding preferences prevented boarding

RejectionContext

Numeric details of a rejection. Fields use OrderedFloat<f64> (for Eq on the event).

FieldTypeDescription
attempted_weightOrderedFloat<f64>Weight the rider attempted to add
current_loadOrderedFloat<f64>Current load on the elevator
capacityOrderedFloat<f64>Maximum weight capacity

Snapshots

WorldSnapshot

Serializable snapshot of the entire simulation state. Capture with sim.snapshot(), restore with snapshot.restore(custom_factory).

FieldTypeDescription
ticku64Simulation tick at capture time
dtf64Time delta per tick
entitiesVec<EntitySnapshot>All entity data
groupsVec<GroupSnapshot>Elevator group data
stop_lookupHashMap<StopId, usize>Stop ID to entity index mapping
metricsMetricsGlobal metrics at capture time
metric_tagsMetricTagsPer-tag metrics and entity-tag associations
extensionsHashMap<String, HashMap<EntityId, String>>Serialized extension data
ticks_per_secondf64Tick rate for TimeAdapter reconstruction
MethodSignatureDescription
restore(self, Option<&dyn Fn(&str) -> Option<Box<dyn DispatchStrategy>>>) -> SimulationRestore 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().

MethodSignatureDescription
new(f64) -> SelfCreate with the given tick rate
ticks_to_seconds(u64) -> f64Convert ticks to seconds
seconds_to_ticks(f64) -> u64Convert seconds to ticks (rounded)
duration_to_ticks(Duration) -> u64Convert a Duration to ticks (rounded)
ticks_to_duration(u64) -> DurationConvert ticks to a Duration
ticks_per_second() -> f64The configured tick rate

Phase

Simulation phase identifiers for hook registration.

VariantDescription
AdvanceTransientAdvance transient rider states (Boarding to Riding, Exiting to Arrived)
DispatchAssign idle elevators to stops via dispatch strategy
MovementUpdate elevator position and velocity
DoorsTick door finite-state machines
LoadingBoard and exit riders
MetricsAggregate 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.

VariantDescription
UniformEqual probability for all pairs
UpPeak80% from lobby, 20% inter-floor
DownPeak80% to lobby, 20% inter-floor
Lunchtime40% upper→mid, 40% mid→upper, 20% random
Mixed30% up-peak, 30% down-peak, 40% inter-floor
MethodSignatureDescription
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.

MethodSignatureDescription
new(Vec<(Range<u64>, TrafficPattern)>) -> SelfBuild from segment list
with_fallback(self, TrafficPattern) -> SelfSet the out-of-range fallback
constant(TrafficPattern) -> SelfSchedule with a single pattern for all ticks
office_day(ticks_per_hour: u64) -> Self9-hour office day preset
pattern_at(&self, u64) -> &TrafficPatternGet 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.

MethodSignatureDescription
new(Vec<StopId>, TrafficSchedule, u32, (f64, f64)) -> SelfBuild with stops, schedule, mean interval, weight range
from_config(&SimConfig) -> SelfBuild from simulation config
with_schedule(self, TrafficSchedule) -> SelfReplace the schedule
with_mean_interval(self, u32) -> SelfReplace the mean arrival interval
with_weight_range(self, (f64, f64)) -> SelfReplace 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:

  1. Loads config from a RON file (CLI argument or assets/config/default.ron)
  2. Creates a Simulation and inserts it as the SimulationRes resource
  3. Inserts SimSpeed resource for controlling simulation speed
  4. Registers the EventWrapper message for bridging sim events to Bevy
  5. 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 paused
  • multiplier: 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:

ModuleResponsibility
plugin.rsElevatorSimPlugin – loads config, creates sim, registers everything
sim_bridge.rsSimulationRes, SimSpeed, EventWrapper, tick system
rendering.rs2D visualization of the building, elevators, and riders
ui.rsHUD overlay showing metrics and simulation state
camera.rsCamera setup sized to the building
input.rsKeyboard controls for speed adjustment
passenger_ai.rsTimer-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:

  1. Build the Simulation once, up front. SimulationBuilder::new() or SimulationBuilder::from_config(config).build(). Keep the Simulation as state in your engine’s scene / app struct / actor.

  2. 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.

  3. Read state out, inject input in.

    • Read state: sim.world() returns a World you can query via query::<(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.

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-deny supply-chain check green. The code sketch below is correct and will run if you add macroquad = "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

HostWhen it’s the right fit
headless / CLIAnalysis, batch runs, CI, web backends (stream Events over SSE / WebSocket to a JS frontend).
macroquad2D games, rapid iteration, WASM browser builds. Minimal dep footprint.
eframe / eguiDashboards, inspectors, debuggers. Good when you want live-editable sim state with sliders + buttons.
BevyFull 3D games, ECS-native integration, complex scene systems. See Bevy Integration.
Wasm-in-browserAny 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