Introduction
elevator-core is an engine-agnostic elevator simulation library written in pure Rust. You define stops at arbitrary positions on a 1-D axis, spawn riders, and step the tick loop — the engine handles dispatch, trapezoidal motion, doors, boarding, and metrics. Your code reacts to a typed event stream.
▶ Try it live: the in-browser playground runs the full simulation. Race two dispatch strategies on the same traffic, side-by-side, no install.
Who this is for
- Game developers dropping a ready-made elevator into Bevy, macroquad, Godot, Unity, or a homebrew engine.
- Algorithm researchers prototyping dispatch strategies (SCAN, LOOK, ETD, RSR, or your own) on a deterministic, snapshot-able simulation.
- Educators teaching scheduling, real-time systems, or queueing through a visual, interactive testbed.
- Hobbyists who think elevators are neat. (You’re correct.)
What you can build
The library models stops at arbitrary distances along a shaft axis, not uniform floors. Position is a plain f64; the bundled assets/config/space_elevator.ron stretches the same engine to 1,000 distance units between two stops as a stress test. A few examples:
| Scenario | How |
|---|---|
| Office building with 5 floors | Stops at 0, 4, 8, 12, 16 |
| Skyscraper with sky lobbies | Multi-group dispatch with express zones |
| Space elevator / orbital tether | Stops at 0 and 1,000 — same engine |
| Player-controlled car | ServiceMode::Manual + direct velocity commands |
| Custom AI dispatch | Implement DispatchStrategy::rank() |
| VIP passengers, cargo, robots | Extension storage — attach any typed data |
The core crate provides primitives, not opinions. A rider is anything that rides; you decide whether they are tenants, hotel guests, hospital patients, miners, or freight pallets, and attach the semantics through the extension storage system.
What it isn’t
- Not a renderer. No graphics, no windowing, no audio. The core crate is headless; see Bevy Integration for a 2-D visual wrapper.
- Not real-time. The tick loop runs as fast as you drive it. There is no wall-clock coupling — a tick is whatever
ticks_per_secondsays it is. Games layer real-time scheduling on top. - Not an ECS framework. It uses an ECS-inspired internal layout but exposes a focused simulation API, not a general-purpose ECS.
- Not networked or multi-building. One simulation per process. Federation, multiplayer, and cross-building routing are out of scope.
- Not an optimizer. Built-in dispatch strategies are reference implementations — fast enough for most consumers, but not tuned for any specific building. Bring your own algorithm if you need optimal performance.
Workspace at a glance
The simulation core sits at the centre. Host crates wrap it for a target engine; supporting crates exist only to keep the hosts in sync.
flowchart TB
core["<b>elevator-core</b><br/>simulation library<br/>(pure Rust, no engine deps)"]
subgraph hosts ["Hosts"]
bevy["elevator-bevy<br/>2-D visual frontend"]
wasm["elevator-wasm<br/>browser playground"]
ffi["elevator-ffi<br/>C ABI: Unity, .NET, GameMaker"]
gdext["elevator-gdext<br/>Godot extension"]
tui["elevator-tui<br/>terminal viewer / smoke runner"]
end
subgraph supporting ["Supporting"]
contract["elevator-contract<br/>cross-host determinism harness"]
layout["elevator-layout-*<br/>repr(C) codegen for hosts"]
end
core --> bevy
core --> wasm
core --> ffi
core --> gdext
core --> tui
contract -.validates.-> ffi
contract -.validates.-> wasm
layout -.codegens for.-> ffi
elevator-core is the only crate published to crates.io as a library; the hosts ship through their target ecosystems. See Supporting Crates for what each supporting crate does and Using the Bindings for host integration walkthroughs.
Determinism in one paragraph
Given the same initial config and the same sequence of API calls, the simulation is byte-for-byte deterministic. The core loop contains no internal randomness; every tick phase is pure over the world state. The built-in PoissonSource traffic generator uses an OS-seeded RNG and is not deterministic — for reproducible traffic, plug in a seeded TrafficSource. See Snapshots and Determinism for the contract, save/load, and replay patterns.
Stability and MSRV
- MSRV: Rust 1.88 (uses let-chains, stabilised in 1.88; a CI job pinned to the exact MSRV keeps this honest).
- Versioning: Semver. Breaking changes bump the major version. Adding variants to
#[non_exhaustive]enums (events, errors) is not breaking. - Release cadence: managed via release-please; per-crate changelogs live under
crates/*/CHANGELOG.md. - Per-item classification: see Stability and Versioning.
Links
Next steps
- Quick Start — build your first simulation in under 30 lines.
- Stops, Lines, and Groups — the topology model that lets one engine run an office, a skyscraper, or a space tether.
- Supporting Crates — supporting crates that keep the host bindings honest.
Quick Start
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.
Add the dependency
cargo add elevator-core
Import the prelude
The prelude re-exports the names that cover most usage — building a simulation, stepping it, querying world state, writing custom dispatch, and reading aggregate metrics:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
}
This brings in Simulation, SimulationBuilder, RiderBuilder, SimConfig, ElevatorConfig, StopConfig, the typed IDs (StopId, ElevatorId, RiderId, EntityId, GroupId), Event, SimError, RejectionReason, the phase + direction enums (ElevatorPhase, RiderPhase, Direction), the dispatch trait + presets (DispatchStrategy, BuiltinStrategy, BuiltinReposition), and World + Metrics. Fine-grained component types (Position, Speed, Route, Patience, …), per-strategy structs (ScanDispatch, EtdDispatch, …), extension keys (ExtKey), and traffic types are imported explicitly from their sub-modules when needed.
Feature flags
| Flag | Default? | Enables |
|---|---|---|
traffic | yes | PoissonSource, TrafficPattern, TrafficSchedule. Pulls in rand. |
energy | no | Per-elevator EnergyProfile/EnergyMetrics components. |
Turn off defaults with default-features = false for a leaner build.
Build a simulation
Use SimulationBuilder to set up a 3-stop building with one elevator. ElevatorConfig has sensible defaults (max speed 2.0, acceleration 1.5, deceleration 2.0, 800 kg capacity), so you only need to specify stop positions and a starting stop:
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(())
}
Spawn a rider
A rider is anything that rides an elevator. Provide an origin stop, a destination stop, and a weight:
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
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(
StopId(0), // origin: Lobby
StopId(2), // destination: Floor 3
75.0, // weight in kg
)?;
Ok(())
}
spawn_rider maps config-level StopId values to runtime EntityId values internally. It returns Result<RiderId, SimError> – it fails if you pass a StopId that doesn’t exist in your building. RiderId is a typed wrapper around EntityId; use .entity() to get the inner EntityId when needed.
Run the simulation loop
Each call to sim.step() advances the simulation by one tick, running every phase of the tick loop. After stepping, drain events to see what happened:
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
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(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 {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.entity() {
arrived = true;
}
}
_ => {}
}
}
}
println!("\n--- Summary ---");
println!("Total ticks: {}", sim.current_tick());
println!("{}", sim.metrics());
Ok(())
}
The complete program
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> {
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()?;
let rider_id = sim.spawn_rider(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 {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.entity() {
arrived = true;
}
}
_ => {}
}
}
}
println!("\n--- Summary ---");
println!("Total ticks: {}", sim.current_tick());
println!("{}", sim.metrics());
Ok(())
}
What just happened?
- The builder created a
Simulationcontaining aWorldwith three stop entities and one elevator entity, plus a SCAN dispatch strategy (the default). spawn_ridercreated a rider entity at the Lobby with a route to Floor 3.- Each
step()ran the tick loop. Dispatch noticed a waiting rider and sent the elevator to the Lobby. Movement moved the elevator using a trapezoidal velocity profile. Doors opened and closed. Loading boarded and exited the rider. Metrics updated aggregate stats. - Events fired at each significant moment, and we pattern-matched on them to detect arrival.
Next steps
- Configuration – load buildings from RON files instead of building in code
- Stops, Lines, and Groups – understand the building topology model
- Dispatch Strategies – choose or write dispatch algorithms
Configuration
The simulation can be configured entirely in code or loaded from a RON config file. Both paths 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. 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)
.elevator(ElevatorConfig {
id: elevator_core::config::ElevatorConfigId(0),
name: "Express A".into(),
max_speed: 5.0.into(),
acceleration: 2.0.into(),
deceleration: 3.0.into(),
weight_capacity: 1200.0.into(),
starting_stop: StopId(0),
door_open_ticks: 60,
door_transition_ticks: 15,
..Default::default()
})
.building_name("Skyline Tower")
.ticks_per_second(60.0)
.dispatch(EtdDispatch::new())
.build()?;
Ok(())
}
Override any ElevatorConfig field with struct-update syntax – ElevatorConfig { max_speed: 4.0, ..Default::default() }.
ElevatorConfig fields
| Field | Type | Description | Default |
|---|---|---|---|
id | ElevatorConfigId | Unique numeric ID within the config (mapped to EntityId at runtime) | – |
name | String | Human-readable name for UIs and logs | – |
max_speed | Speed | Maximum travel speed (distance units/second) | 2.0 |
acceleration | Accel | Acceleration rate | 1.5 |
deceleration | Accel | Deceleration rate | 2.0 |
weight_capacity | Weight | Maximum total rider weight | 800.0 |
starting_stop | StopId | Where this elevator starts | – |
door_open_ticks | u32 | Ticks doors stay fully open | 10 |
door_transition_ticks | u32 | Ticks for a door open/close transition | 5 |
restricted_stops | Vec<StopId> | Stops this elevator cannot serve | [] |
service_mode | Option<ServiceMode> | Initial service mode | None (Normal) |
inspection_speed_factor | f64 | Speed multiplier in Inspection mode | 0.25 |
RON config files
For data-driven workflows, 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 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.
SimulationParams
Controls simulation timing. 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:
| Field | Meaning |
|---|---|
mean_interval_ticks | Average ticks between passenger spawns (Poisson distribution) |
weight_range | (min, max) for uniformly distributed rider weight |
Long shafts and arbitrary distances
Stops carry arbitrary f64 positions, so the same engine drives a five-floor office and a kilometres-deep mine. The example below treats one position unit as one metre – the engine doesn’t enforce a unit, so any internally-consistent convention works.
A deep mine shaft is a useful stress test: real production hoists at sites like Mponeng run skips at 16-18 m/s over 2-3 km of vertical travel, with cage-loading dwell times measured in tens of seconds rather than the few seconds an office cab needs.
SimConfig(
building: BuildingConfig(
name: "Mine Shaft 1",
stops: [
StopConfig(id: StopId(0), name: "Surface", position: 0.0),
StopConfig(id: StopId(1), name: "Mid-level", position: -1200.0),
StopConfig(id: StopId(2), name: "Bottom", position: -2400.0),
],
),
elevators: [
ElevatorConfig(
id: 0,
name: "Cage A",
max_speed: 18.0, // ~m/s, deep-shaft hoist class.
acceleration: 1.5,
deceleration: 2.5,
weight_capacity: 12000.0, // miners + skip ore + tools.
starting_stop: StopId(0),
door_open_ticks: 120, // crew loading takes time.
door_transition_ticks: 30,
),
],
// ...
)
The stops sit 1,200 metres apart (under the metre convention chosen above), positions are negative (down from the surface datum), and the doors hold open six times longer than an office default. The same simulation engine handles both a five-story office and a 2.4 km mine shaft – the only thing that changes is the config.
The repository also bundles assets/config/space_elevator.ron – two stops separated by 1,000 distance units (interpret the unit however you like) – as a stress test for very high max_speed values and very long door cycles.
Validation
Config is validated at construction time (in SimulationBuilder::build()). Invalid configs produce a SimError::InvalidConfig with a descriptive message. Validation checks include:
- At least one stop
- No duplicate
StopIdvalues - At least one elevator
- All physics parameters positive
- Each elevator’s
starting_stopreferences an existing stop - Tick rate is positive
Next steps
- Stops, Lines, and Groups – understand multi-line and multi-group topology
- Dispatch Strategies – choose a dispatch algorithm for your building
- Traffic Generation – use
PassengerSpawnConfigwith the traffic module
Stops, Lines, and Groups
This chapter covers the spatial and organizational building blocks of an elevator-core simulation: where elevators travel, how shafts are modeled, and how cars are grouped for dispatch.
Stops
A stop is a named position along a 1D shaft axis. Unlike traditional elevator simulators that assume uniform floor spacing, elevator-core places stops at arbitrary distances. A 5-story office might have stops at 0.0, 3.5, 7.0, 10.5, and 14.0. A space elevator might have stops at 0.0 and 35,786,000.0. The engine does not care – the physics just scale.
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn main() -> Result<(), SimError> {
let sim = SimulationBuilder::new()
.stop(StopId(0), "Basement", -3.0)
.stop(StopId(1), "Lobby", 0.0)
.stop(StopId(2), "Mezzanine", 2.5)
.stop(StopId(3), "Floor 2", 6.0)
.elevator(ElevatorConfig::default())
.build()?;
Ok(())
}
Positions are plain f64 values – the library does not enforce meters, feet, or any other unit. Negative positions are fine (basements below a lobby at 0.0, for instance). There is no privileged zero or required origin.
Lines
A line represents a physical path: a shaft, a tether, a track. Every elevator belongs to exactly one line, and a line defines which stops its elevators can reach.
For simple single-shaft buildings, you never need to think about lines. The builder auto-creates a default line that includes all stops and all elevators:
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn main() -> Result<(), SimError> {
// This implicitly creates one line containing both elevators and all stops.
SimulationBuilder::new()
.stop(StopId(0), "Ground", 0.0)
.stop(StopId(1), "Top", 10.0)
.elevator(ElevatorConfig { id: elevator_core::config::ElevatorConfigId(0), ..Default::default() })
.elevator(ElevatorConfig { id: elevator_core::config::ElevatorConfigId(1), ..Default::default() })
.build()?;
Ok(())
}
Lines matter when you have separate shafts serving different sets of stops – a low-rise bank that only reaches floors 1-20 and a high-rise bank that serves 20-40, for example.
Groups
A group is a dispatch unit: one or more lines that share a single dispatch strategy. The dispatch system evaluates demand and assigns elevators within each group independently.
The simplest simulations have one group (auto-created by the builder). Multi-group configurations are common in tall buildings where low-rise and high-rise banks operate independently, each with their own dispatch algorithm.
A stop can appear in multiple groups. A sky lobby served by both the low-rise and high-rise banks is a single stop entity referenced by lines in both groups.
Entity relationships
The full hierarchy looks like this:
graph TD
G["Group
*GroupId*"] -->|owns| L1["Line A
*EntityId*"]
G -->|owns| L2["Line B
*EntityId*"]
L1 -->|contains| E1["Elevator 1
*EntityId*"]
L1 -->|contains| E2["Elevator 2
*EntityId*"]
L2 -->|contains| E3["Elevator 3
*EntityId*"]
L1 -->|serves| S0["Stop: Lobby
*EntityId*"]
L1 -->|serves| S1["Stop: Floor 10
*EntityId*"]
L2 -->|serves| S0
L2 -->|serves| S2["Stop: Floor 20
*EntityId*"]
Key invariants:
- An elevator always belongs to exactly one line (
elevator.linepoints to aLineentity) - A line always belongs to exactly one group (
line.grouppoints to aGroupId) - A stop may be shared across lines and groups
- Riders aboard an elevator appear in both
elevator.ridersand carryRiderPhase::Riding(elevator_id)
Identity types
The library uses five identity types. Knowing which to reach for saves confusion:
| Type | Identifies | When you use it |
|---|---|---|
EntityId | Any entity at runtime (stop, elevator, rider) | Event payloads, world lookups, dispatch decisions |
StopId | A stop in the config (e.g., StopId(0)) | Builder API, config files, spawn_rider |
GroupId | An elevator group (e.g., GroupId(0)) | Multi-group dispatch, group-specific hooks |
ElevatorId | A specific elevator entity | Public API surfaces that act on a car (set_service_mode, push_destination, manual control) |
RiderId | A specific rider entity | Public API surfaces that act on a rider (despawn_rider, settle_rider, set_rider_tag) |
ElevatorId and RiderId are phantom-typed wrappers over EntityId. They exist to give the public API surface type safety – spawn_rider returns a RiderId, not a bare EntityId, so you can’t accidentally pass a stop id where a rider id is required. Internally, World and event payloads use the untyped EntityId.
StopId is a config-level concept. When the simulation boots, each StopId is mapped to an EntityId. At runtime you work with EntityId everywhere. Convert when needed:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &Simulation) {
let lobby_entity: Option<EntityId> = sim.stop_entity(StopId(0));
let _ = lobby_entity;
}
}
Coordinate system
- Axis. All positions are scalars along a single 1D axis. Higher values mean higher up (or further along for horizontal configurations). There is no 2D/3D geometry in the core.
- Units. Unspecified – positions, velocities, accelerations, and weights are
f64values. Keep them internally consistent. The suggested convention is meters, kilograms, and ticks, but the library does not enforce this. - Origin. No privileged zero. Stop 0 does not have to be at position 0.0. Negative positions are allowed.
Time
The fundamental unit of time is the tick. Each call to sim.step() advances the simulation by one tick.
Convert between ticks and seconds using the time API:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &Simulation) {
let seconds = sim.time().ticks_to_seconds(120); // 120 ticks -> seconds
let _ = seconds;
}
}
The conversion uses ticks_per_second from your config (default: 60). At 60 ticks/second, 120 ticks = 2.0 seconds.
Next steps
- Elevators – how elevator cars move through their phase lifecycle
- Configuration – defining stops, lines, and groups in code or RON files
- Dispatch Strategies – how groups assign elevators to calls
Elevators
An elevator in elevator-core is an entity with physics, a door state machine, direction indicators, and a phase that tracks what it is currently doing. This chapter covers how to read elevator state, understand its lifecycle, and configure its behavior.
Elevator phases
Every elevator is in exactly one phase at any time. The ElevatorPhase enum drives what the simulation does with the car each tick:
| Phase | Meaning |
|---|---|
Idle | No target. Waiting for dispatch to assign a stop. |
MovingToStop(EntityId) | Traveling toward a target stop. |
Repositioning(EntityId) | Moving to a stop for coverage, not to serve a call. |
DoorOpening | Doors are currently opening at a stop. |
Loading | Doors fully open. Riders may board or exit. |
DoorClosing | Doors are currently closing. |
Stopped | At a stop, doors closed, awaiting next dispatch decision. |
The typical cycle when an elevator is dispatched to a stop:
Idle -> MovingToStop -> DoorOpening -> Loading -> DoorClosing -> Stopped
From Stopped, dispatch can assign a new target (back to MovingToStop) or leave the car idle. Repositioned elevators skip the door cycle entirely – they go from Repositioning directly to Idle on arrival.
Reading elevator state
Access elevator data through the simulation or the world directly:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &Simulation, elevator_id: EntityId) {
// World accessors return Option -- unwrap when you know the entity exists.
let pos: f64 = sim.world().position(elevator_id).unwrap().value();
let vel: f64 = sim.world().velocity(elevator_id).unwrap().value();
// The Elevator component has typed getters.
let elev = sim.world().elevator(elevator_id).unwrap();
let phase = elev.phase();
let load = elev.current_load();
let capacity = elev.weight_capacity();
let _ = (pos, vel, phase, load, capacity);
}
}
Direction indicators
Every elevator carries two indicator lamps: going_up and going_down. Together they tell waiting riders – and the loading system – which direction the car will serve next.
going_up | going_down | Meaning |
|---|---|---|
true | true | Idle – will accept riders in either direction |
true | false | Committed to an upward trip |
false | true | Committed to a downward trip |
The dispatch phase auto-manages these lamps:
- On
DispatchDecision::GoToStop(target), indicators are set based on target position vs. current position. - On
DispatchDecision::Idle, the pair resets to(true, true). - A
DirectionIndicatorChangedevent fires only when the pair actually changes.
The loading phase uses these lamps as a boarding filter. A rider heading up will not board a car with going_up = false, and vice versa. The rider is silently left waiting – no rejection event – so a later car heading in their direction picks them up. Idle cars (both lamps lit) accept riders in either direction.
Read the lamps through the simulation API:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &Simulation, elevator_id: ElevatorId) {
let going_up = sim.elevator_going_up(elevator_id);
let going_down = sim.elevator_going_down(elevator_id);
let _ = (going_up, going_down);
}
}
Or directly from the component:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &Simulation, elevator_id: EntityId) {
let elev = sim.world().elevator(elevator_id).unwrap();
let going_up = elev.going_up();
let going_down = elev.going_down();
let _ = (going_up, going_down);
}
}
Physics
Each elevator has its own physics parameters, stored on the Elevator component. The movement phase applies a trapezoidal velocity profile: accelerate up to max speed, cruise, then decelerate to stop precisely at the target position. This produces smooth, realistic motion without requiring a full physics engine.
The profile is computed per-tick from three values:
max_speed– top travel speedacceleration– rate of speed increasedeceleration– rate of speed decrease (braking)
All three use your simulation’s distance and time units. If you are working in meters and ticks at 60 ticks/second, a max_speed of 2.0 means 2 meters per second.
ElevatorConfig fields
When constructing elevators, use ElevatorConfig to set initial parameters:
| Field | Type | Description | Default |
|---|---|---|---|
id | u32 | Unique numeric ID within the config (mapped to EntityId at runtime) | – |
name | String | Human-readable name for UIs and logs | – |
max_speed | Speed | Maximum travel speed (distance units/second) | 2.0 |
acceleration | Accel | Acceleration rate (distance units/second^2) | 1.5 |
deceleration | Accel | Deceleration rate (distance units/second^2) | 2.0 |
weight_capacity | Weight | Maximum total rider weight | 800.0 |
starting_stop | StopId | Where this elevator starts | – |
door_open_ticks | u32 | Ticks doors stay fully open | 10 |
door_transition_ticks | u32 | Ticks for a door open/close transition | 5 |
restricted_stops | Vec<StopId> | Stops this elevator cannot serve | [] |
service_mode | Option<ServiceMode> | Initial service mode | None (Normal) |
inspection_speed_factor | f64 | Speed multiplier in Inspection mode | 0.25 |
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn main() -> Result<(), SimError> {
let sim = SimulationBuilder::new()
.stop(StopId(0), "Ground", 0.0)
.stop(StopId(1), "Top", 10.0)
.elevator(ElevatorConfig {
id: elevator_core::config::ElevatorConfigId(0),
name: "Express A".into(),
max_speed: 5.0.into(),
acceleration: 2.0.into(),
deceleration: 3.0.into(),
weight_capacity: 1200.0.into(),
starting_stop: StopId(0),
door_open_ticks: 60,
door_transition_ticks: 15,
..Default::default()
})
.build()?;
Ok(())
}
All physics parameters must be positive. Invalid values are rejected at build time with SimError::InvalidConfig.
Runtime upgrades
Physics parameters, capacity, and door timing can be changed on a running elevator. This is useful for tycoon-style games where players upgrade elevators:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &mut Simulation, elev: ElevatorId) -> Result<(), SimError> {
sim.set_max_speed(elev, 4.0)?;
sim.set_acceleration(elev, 2.5)?;
sim.set_deceleration(elev, 3.0)?;
sim.set_weight_capacity(elev, 1500.0)?;
sim.set_door_open_ticks(elev, 30)?;
sim.set_door_transition_ticks(elev, 8)?;
Ok(())
}
}
Each setter emits an ElevatorUpgraded event with the field name, old value, and new value. Changes take effect on the next tick.
Door state machine
Doors cycle through four states with configurable timing:
Closed -> Opening (transition_ticks) -> Open (open_ticks) -> Closing (transition_ticks) -> Closed
door_transition_tickscontrols how long the opening and closing animations take.door_open_tickscontrols how long doors stay fully open before closing.- Riders can only board or exit during the
Loadingphase, which runs while doors are fully open. DoorOpenedandDoorClosedevents fire at the appropriate transitions.
Next steps
- Riders – spawning riders and tracking populations
- The Simulation Loop – how elevator phases advance each tick
- Movement and Physics – deep dive into the trapezoidal velocity profile
Riders
A rider is anything that rides an elevator. The core library is deliberately generic – a rider could be a person, a cargo crate, a robot, or a game character. Your game adds meaning through extensions and game logic; the simulation handles movement, queuing, and capacity.
Spawning riders
The simplest way to create a rider is spawn_rider, which takes an origin stop, a destination stop, and a weight:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &mut Simulation) -> Result<(), SimError> {
let rider_id = sim.spawn_rider(StopId(0), StopId(3), 75.0)?;
let _ = rider_id;
Ok(())
}
}
This creates a rider at stop 0, heading to stop 3, weighing 75 units. The rider starts in the Waiting phase and a RiderSpawned event is emitted.
For more control, use the RiderBuilder fluent API:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &mut Simulation) -> Result<(), SimError> {
let rider_id = sim.build_rider(StopId(0), StopId(3))?
.weight(80.0)
.patience(600) // abandon after 10 seconds at 60 tps
.preferences(
Preferences::default()
.with_skip_full_elevator(true)
)
.spawn()?;
let _ = rider_id;
Ok(())
}
}
The builder lets you set patience, boarding preferences, access control, and other per-rider options in a single chain.
Rider phases
Each rider is in one phase at a time. The phases and their transitions are covered in detail in Rider Lifecycle. Here is the overview:
| Phase | Where is the rider? |
|---|---|
Waiting | At a stop, in the queue |
Boarding | Being loaded into an elevator (transient, one tick) |
Riding | Inside an elevator |
Exiting | Leaving an elevator (transient, one tick) |
Walking | Transferring between stops on a multi-leg route |
Arrived | Reached destination – your game decides next step |
Abandoned | Gave up waiting – your game can settle or despawn |
Resident | Parked at a stop, not seeking an elevator |
Each transition emits an event: RiderSpawned, RiderBoarded, RiderExited, RiderAbandoned, RiderSettled, RiderRerouted, RiderDespawned.
Rider data
Key accessors on the Rider component (all are getter methods):
weight– how much this rider contributes to elevator loadspawn_tick– when the rider was createdboard_tick– when the rider boarded (if applicable)current_stop–Option<EntityId>, the stop the rider is currently at (orNoneif aboard an elevator)phase– the currentRiderPhase
Population tracking
The simulation maintains a reverse index (RiderIndex) that tracks riders at each stop, enabling O(1) population queries without scanning every entity:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &Simulation, lobby_entity: EntityId, floor_10: EntityId, stop_entity: EntityId) {
// Who is waiting at the lobby?
let waiting: Vec<EntityId> = sim.waiting_at(lobby_entity).collect();
// How many residents live on floor 10?
let count = sim.residents_at(floor_10).count();
// Did anyone abandon at this stop?
let abandoned: Vec<EntityId> = sim.abandoned_at(stop_entity).collect();
let _ = (waiting, count, abandoned);
}
}
These three methods cover the main population categories:
| Method | Returns riders in phase… |
|---|---|
sim.waiting_at(stop) | Waiting |
sim.residents_at(stop) | Resident |
sim.abandoned_at(stop) | Abandoned |
Entity type checks
When you have an EntityId and need to know what it refers to, use the type-check helpers:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &Simulation, id: EntityId) {
if sim.is_rider(id) {
// handle rider
} else if sim.is_elevator(id) {
// handle elevator
} else if sim.is_stop(id) {
// handle stop
}
}
}
These are more readable than querying sim.world().elevator(id).is_some() and are the preferred pattern in game code.
Lifecycle methods
Three methods manage rider state transitions after arrival or abandonment:
| Method | Effect |
|---|---|
sim.settle_rider(id) | Transitions an Arrived or Abandoned rider to Resident at their current stop |
sim.reroute(id, route) | Replaces a Waiting or Resident rider’s route (Resident -> Waiting transition included) |
sim.despawn_rider(id) | Removes the rider entity and updates all indexes |
Always use sim.despawn_rider(id) instead of calling world.despawn() directly – it keeps the population index consistent and emits a RiderDespawned event.
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &mut Simulation, rider_id: RiderId, new_route: Route) -> Result<(), SimError> {
// A rider arrives at their destination. Settle them as a resident.
sim.settle_rider(rider_id)?;
// Later, they want to go back down. Reroute them with a new route —
// the gateway dispatches on phase: Waiting riders get the route in
// place; Resident riders transition back to Waiting first.
sim.reroute(rider_id, new_route)?;
// Or remove them entirely.
sim.despawn_rider(rider_id)?;
Ok(())
}
}
Next steps
- Rider Lifecycle – detailed phase transitions, patience, and abandonment
- Hall Calls and Car Calls – how rider demand becomes dispatch input
- Extensions – attaching game-specific data to riders
Hall Calls and Car Calls
Real elevators don’t know rider destinations when a hall button is pressed – they see direction only. The destination is revealed once the rider boards and presses a floor button inside the cab. Modern destination-dispatch systems (DCS) break that model: riders enter their destination at a lobby kiosk, so the controller knows it up-front.
elevator-core models both designs via hall calls (up/down buttons at each stop) and car calls (floor buttons inside each cab), with per-group mode selection.
Data model
| Component | Keyed by | Lifetime |
|---|---|---|
HallCall | (stop, direction) – at most two per stop | Press through arrival of an assigned car in the matching direction |
CarCall | (car, floor) – one per aboard rider’s destination | Boarding through exit at that floor |
Classic vs Destination mode
Two modes are available, chosen per group via HallCallMode:
HallCallMode::Classic(default) – traditional collective control. Hall calls carry direction only; theCarCallreveals the destination after boarding.HallCallMode::Destination– DCS mode. Hall calls carry a destination from the moment they are pressed (kiosk entry). Required byDestinationDispatch.
use elevator_core::prelude::*;
use elevator_core::dispatch::HallCallMode;
fn main() -> Result<(), SimError> {
let mut sim = SimulationBuilder::demo().build()?;
for g in sim.groups_mut() {
g.set_hall_call_mode(HallCallMode::Destination);
g.set_ack_latency_ticks(5); // 5-tick controller latency
}
Ok(())
}
Lifecycle
A hall call moves through four stages:
- Press – either implicit (via
sim.spawn_rider()) or explicit (sim.press_hall_button()). The first press for a given(stop, direction)emitsHallButtonPressed. - Acknowledge – after the group’s
ack_latency_tickshave elapsed, the call becomes visible to dispatch andHallCallAcknowledgedfires. This models real-world controller latency. - Assign – dispatch commits a car and writes it to
HallCall::assigned_cars_by_line, keyed by the car’s line entity. Stops shared by multiple lines (e.g. a sky-lobby served by low, high, and express banks) carry one entry per line; within a single line the latest assignment replaces the previous one. Games can read a single representative car viasim.assigned_car(stop, direction)for lobby displays, or the full per-line set viasim.assigned_cars_by_line(stop, direction). - Clear – when the assigned car opens doors at the stop with direction indicators matching the call direction, the
HallCallis removed andHallCallClearedfires.
Car calls follow the same pattern: CarButtonPressed fires on the first press per (car, floor), and the loading phase removes a CarCall when the last pending rider for that floor exits.
Scripted control
Game and operator code can drive the call system outside the normal rider flow. A common case: a hospital service elevator has to be commandeered for a code blue – pull the nearest service car to the ER for a priority pickup, override dispatch so this specific car (not whichever one dispatch would have chosen) answers that hall call, then release the override once the call is served.
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
use elevator_core::components::hall_call::CallDirection;
fn run(
sim: &mut Simulation,
er: EntityId,
service_car: ElevatorId,
) -> Result<(), SimError> {
// Code blue: the transport team presses the up button at the ER.
sim.press_hall_button(er, CallDirection::Up)?;
// Pin the priority car to that specific (stop, direction) call so
// dispatch commits it to the ER pickup instead of whatever it would
// have chosen on its own.
sim.pin_assignment(service_car, er, CallDirection::Up)?;
// The car was mid-route on a routine call -- abort it so the car
// brakes immediately instead of finishing that leg before serving
// the priority transport.
sim.abort_movement(service_car)?;
// The patient boards at the ER and rides to the ICU via the normal
// car-call path -- the destination is set when the rider boards, not
// by the pin. Once the hall call has been served, release the pin so
// the car returns to the general dispatch pool.
sim.unpin_assignment(er, CallDirection::Up);
Ok(())
}
}
A pinned car that is mid-door-cycle (Loading / DoorOpening / DoorClosing) finishes its current cycle first; the pin takes effect on the next dispatch tick. Pins that cross lines (the car’s line cannot reach the stop) return SimError::LineDoesNotServeStop rather than silently orphaning the call.
Rider preferences
The Preferences component has two knobs for game-designer-tuned rider behavior:
abandon_after_ticks: Option<u32>– the rider abandons after this many ticks of waiting. UsesPatience::waited_tickswhen present, so multi-leg routes don’t over-count ride time.abandon_on_full: bool– when set, a rider filtered out of a car viaskip_full_elevatorabandons immediately rather than waiting for the next one. EmitsRiderAbandonedon the spot.
Both knobs generate events (RiderSkipped, RiderAbandoned) so game UI can react to individual behavioral beats. See Rider Lifecycle – Preferences for the full details on how these interact.
Public query API
| Method | Purpose |
|---|---|
sim.hall_calls() | Iterator over every active hall call – use for lobby lamp panels, per-floor button animation |
sim.car_calls(car) | Floor buttons currently pressed inside a car – use for cab button-panel rendering |
sim.assigned_car(stop, direction) | DCS-style “your elevator will be car B” indicator (first entry at multi-line stops) |
sim.assigned_cars_by_line(stop, direction) | Full (line, car) list at a stop; one entry per line with a committed car |
sim.waiting_counts_by_line_at(stop) | Waiting-rider count per line; splits the queue for multi-line rendering |
sim.eta_for_call(stop, direction) | Countdown timer for hall displays |
Events
| Event | When | Notes |
|---|---|---|
HallButtonPressed | First press per (stop, direction) | Pre-latency; use for button-light animation |
HallCallAcknowledged | Ack-latency window elapsed | UI confirmation signal |
HallCallCleared | Assigned car opens doors at stop | Clears the button light |
CarButtonPressed | First press per (car, floor) | rider field is None for synthetic presses |
RiderSkipped | Preference filter rejects a candidate car | Rider may still board a later car unless abandon_on_full is set |
FFI
Unity and native consumers can drive the call layer through the elevator-ffi C ABI. See the FFI module for ev_sim_press_hall_button, ev_sim_press_car_button, ev_sim_pin_assignment, ev_sim_unpin_assignment, ev_sim_assigned_car, ev_sim_assigned_cars_by_line, ev_sim_eta_for_call, and the EvHallCall snapshot record. EvHallCall.assigned_car keeps its historical single-value shape (returning whichever line has the numerically smallest entity id); use ev_sim_assigned_cars_by_line to iterate every line’s assignment at a shared stop.
Next steps
- Dispatch Strategies – how hall calls feed into elevator assignment
- Rider Lifecycle – what happens after a rider boards
- The Simulation Loop – where hall calls are processed in the tick phases
The Simulation Loop
Each call to sim.step() runs one simulation tick. A tick consists of eight phases, always executed in the same order. Understanding this loop is essential for predicting when events fire, when state changes become visible, and where to inject custom logic.
The 8-phase tick
flowchart TD
STEP["sim.step()"] --> P1
P1["Phase 1: Advance Transient"] --> P2["Phase 2: Dispatch"]
P2 --> P3["Phase 3: Reposition"]
P3 --> P4["Phase 4: Advance Queue"]
P4 --> P5["Phase 5: Movement"]
P5 --> P6["Phase 6: Doors"]
P6 --> P7["Phase 7: Loading"]
P7 --> P8["Phase 8: Metrics"]
P8 --> ADV["advance_tick()
flush events, tick += 1"]
Events emitted during a tick are buffered internally. After all eight phases complete, advance_tick() drains the buffer into the output queue, making events available to your code via sim.drain_events().
Phase 1: Advance Transient
Riders in one-tick transitional states are advanced to their next phase:
Boarding(elevator)becomesRiding(elevator)– the rider is now inside the car.Exiting(elevator)becomesArrived(route complete) orWaiting(next route leg).- Walk legs are executed immediately – the rider is teleported to the walk destination.
- Patience is ticked for waiting riders. If a rider’s patience expires, they transition to
Abandoned.
This phase ensures that boarding and exiting – set during Loading – take effect at the start of the next tick, giving events a clean one-tick boundary.
Events emitted: RiderAbandoned
Phase 2: Dispatch
The dispatch strategy examines demand and idle elevators, then decides where each car should go. This phase:
- Builds a
DispatchManifestfrom current rider state – who is waiting where, who is riding to where. - Calls each group’s
DispatchStrategy::rank()for every idle/stopped elevator paired with every demanded stop. - Solves the optimal assignment so no two cars are sent to the same hall call.
- Applies assignments: elevators transition to
MovingToStop(stop). - Updates direction indicators based on target position vs. current position.
If an elevator is already at its assigned stop, doors open immediately without a movement phase.
Events emitted: ElevatorAssigned, ElevatorDeparted, DirectionIndicatorChanged
See Dispatch Strategies for details on built-in and custom strategies.
Phase 3: Reposition
Optional phase for idle elevator coverage. Only runs if at least one group has a RepositionStrategy configured.
After dispatch, some elevators may still be Idle (no pending demand). The reposition strategy decides where to send them for better coverage – spreading evenly across stops, returning to a lobby, or positioning near historically high-demand areas.
Repositioned elevators use ElevatorPhase::Repositioning(stop), which is distinct from MovingToStop(stop). On arrival, they go directly to Idle without a door cycle.
Groups without a registered strategy skip this phase entirely.
Events emitted: ElevatorRepositioning
Phase 4: Advance Queue
Reconciles each elevator’s current phase and target with the front of its DestinationQueue. This is where imperative pushes from game code take effect:
sim.push_destination(car, stop)– adds a stop to the back of the queue.sim.push_destination_front(car, stop)– adds a stop to the front, redirecting the car.
An idle elevator with a non-empty queue transitions to MovingToStop(front). An elevator already in transit is redirected if a push_front changed the queue head.
This phase is a no-op for games that never touch the queue – dispatch keeps the queue and target_stop in sync on its own. Queue entries are consumed when a loading cycle completes at the target stop, so imperative and dispatch-driven itineraries compose naturally.
Events emitted: ElevatorAssigned (when a new target is adopted from the queue)
Phase 5: Movement
Applies physics to all elevators in MovingToStop or Repositioning phase. The movement system uses a trapezoidal velocity profile: accelerate up to max speed, cruise, then decelerate to stop precisely at the target position.
Physics parameters (max_speed, acceleration, deceleration) are per-elevator, stored on the Elevator component.
The phase uses the SortedStops resource for O(log n) detection of stops passed during each tick. When an elevator passes a stop without stopping, a PassingFloor event fires – useful for floor counter displays.
On arrival:
- Dispatched elevators (
MovingToStop) transition toDoorOpeningand doors begin opening. EmitsElevatorArrived. - Repositioned elevators (
Repositioning) go directly toIdlewith no door cycle. EmitsElevatorRepositioned.
Events emitted: ElevatorArrived, PassingFloor, ElevatorRepositioned
Phase 6: Doors
Ticks the door finite-state machine for each elevator:
Closed -> Opening (transition_ticks) -> Open (open_ticks) -> Closing (transition_ticks) -> Closed
Phase transitions on completion:
- Finished opening: elevator transitions to
Loading(riders can board/exit). - Finished open hold: elevator transitions to
DoorClosing. - Finished closing: elevator transitions to
Stopped(available for next dispatch).
Timing is per-elevator via door_open_ticks and door_transition_ticks in ElevatorConfig.
Events emitted: DoorOpened, DoorClosed
Phase 7: Loading
Boards and exits riders at elevators in the Loading phase. Uses a two-pass approach:
- Read pass – scans all loading elevators and their stops, collecting actions (Exit, Board, or Reject).
- Write pass – mutates world state based on collected actions.
Rules:
- One rider action per elevator per tick (exit takes priority over boarding).
- Exiting: riders whose destination matches the current stop exit the elevator.
- Boarding: waiting riders enter, subject to weight capacity and direction indicators.
- Riders exceeding remaining capacity are rejected with a typed
RejectionReason.
Direction indicators act as a boarding filter: a rider heading up will not board a car with going_up = false. The rider stays waiting – no rejection event – so a later car in the right direction picks them up.
Events emitted: RiderBoarded, RiderExited, RiderRejected
Phase 8: Metrics
Reads events emitted during the current tick and updates aggregate metrics:
- Spawn count, board count, delivery count, abandonment count
- Wait time distribution (ticks between spawn and board, per rider)
- Ride time distribution (ticks between board and exit, per rider)
- Total distance traveled by all elevators
Also updates per-tag metric accumulators via MetricTags, enabling line-level and custom-tag breakdowns.
This phase is a read-only consumer – it does not emit events.
Hooks
Each phase supports before/after lifecycle hooks, letting you inject custom logic without sub-stepping:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
use elevator_core::hooks::Phase;
fn run(sim: &mut Simulation) {
sim.add_before_hook(Phase::Loading, |world| {
// Custom logic before loading runs
});
}
}
See Lifecycle Hooks for the full hook API.
Sub-stepping
For advanced use cases, you can run individual phases instead of calling step():
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
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_advance_queue();
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, skip phases, or run a phase multiple times. The phase methods can be called in any order, but the standard order exists for a reason – deviating from it may produce unexpected results.
Next steps
- Dispatch Strategies – what happens inside Phase 2
- Lifecycle Hooks – injecting logic at phase boundaries
- Events and Metrics – consuming the events each phase emits
Dispatch Strategies
Dispatch is the brain of an elevator system – it decides which elevator goes where. This chapter covers imperative dispatch, the built-in strategies, and how to choose between them.
How dispatch works
Each tick, the Dispatch phase runs four steps:
flowchart LR
demand["per-stop demand<br/>(waiting riders,<br/>weights, wait times)"] --> manifest["DispatchManifest"]
riding["riding riders<br/>(per destination)"] --> manifest
cars["idle / stopped<br/>elevators per group"] --> rank
manifest --> rank["DispatchStrategy::rank()<br/>scores (car, stop) pairs"]
rank --> hungarian["Hungarian<br/>(Kuhn-Munkres)<br/>solver"]
hungarian --> decision["DispatchDecision<br/>per elevator:<br/>GoToStop(s) or Idle"]
decision --> phase["ElevatorPhase update<br/>+ direction indicator"]
- Build manifest. The simulation collects per-stop demand (waiting riders, weights, wait times) and per-destination riding riders into a
DispatchManifest. - Collect idle elevators. Each group gathers its idle/stopped elevators and their current positions.
- Rank and match. The group’s
DispatchStrategyscores every(car, stop)pair viarank(). The dispatch system feeds all scores into a Hungarian (Kuhn-Munkres) solver to produce the globally optimal assignment – one car per hall call, automatically. - Apply decisions. Each elevator receives a
DispatchDecision: eitherGoToStop(entity_id)to begin moving, orIdleto stay put.
Direction indicators (going_up/going_down) are set automatically from dispatch decisions, so downstream boarding gets direction-awareness with no extra work from the strategy. See Elevators for details.
Imperative dispatch with DestinationQueue
If you want to tell an elevator exactly where to go – bypassing strategy logic entirely – push directly to its DestinationQueue:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
let mut sim: Simulation = todo!();
let elev: ElevatorId = 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 to front of queue
sim.clear_destinations(elev).unwrap(); // cancel all pending stops
sim.abort_movement(elev).unwrap(); // stop the current leg too
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.
clear_destinations is a soft clear – it only drains the pending queue. An elevator that is already mid-flight will finish its current leg and then go idle. To stop a moving car immediately, use abort_movement: it brakes the car along its normal deceleration profile, parks it at the nearest reachable stop (doors stay closed; onboard riders stay aboard), clears the queue, and emits MovementAborted so UI/metrics can react.
Between the Dispatch and Movement phases, the AdvanceQueue phase reconciles each elevator’s phase/target with the front of its queue. Idle elevators with a non-empty queue begin moving; elevators mid-flight whose queue front changed (because you called push_destination_front) are redirected. Movement pops the front on arrival.
You can mix imperative and strategy-driven dispatch freely. Dispatch keeps the queue in sync with its own decisions, so games can observe the queue for visualization and intervene only when needed.
Built-in strategies
| Strategy | Algorithm | Best for | Trade-off |
|---|---|---|---|
ScanDispatch | Sweep end-to-end, reversing at shaft extremes | Single elevator, uniform traffic | Simple and fair; wastes time past the last request |
LookDispatch | Like SCAN, but reverses at the last request in the current direction | Single elevator, sparse traffic | More efficient than SCAN when requests cluster; slightly less predictable |
NearestCarDispatch | Assign each call to the closest idle elevator | Multi-elevator groups | Low average wait; can cause bunching when elevators cluster |
EtdDispatch | Minimize estimated time to destination across all riders | Multi-elevator groups with mixed traffic | Best average performance; higher per-tick computation |
DestinationDispatch | Sticky rider-to-car assignment via lobby kiosk input | Destination-dispatch systems (DCS) | Requires HallCallMode::Destination; best with lobby kiosks |
RsrDispatch | Additive composite: ETA + wrong-direction / load / car-call-affinity terms | Production-style controllers with tunable preferences | Weights default to a nearest-car baseline; opt in per term |
Choosing a strategy
+-- 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
+-- DCS / lobby kiosks -----------> DestinationDispatch
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 or clustered requests).
- NearestCarDispatch – The default 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
delay_weightto balance existing riders vs. new calls.
For priority, weight, fairness, or accessibility-aware dispatch, write a custom strategy – see Writing a Custom Dispatch Strategy.
Swapping strategies on the builder
The builder defaults to ScanDispatch. Call .dispatch() to use a different strategy:
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(())
}
Each built-in lives in its own module:
#![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;
use elevator_core::dispatch::destination::DestinationDispatch;
use elevator_core::dispatch::rsr::RsrDispatch;
}
The ETD strategy accepts a 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;
let etd = EtdDispatch::new(); // default delay_weight = 1.0
let etd_conservative = EtdDispatch::with_delay_weight(1.5); // favor existing riders
let etd_fair = EtdDispatch::new().with_age_linear_weight(1.0); // linear starvation-avoidance (CGC)
}
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. Each group can use 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(())
}
Reposition strategies
After dispatch, idle elevators with no pending demand can be repositioned for better coverage. Configure a RepositionStrategy on the builder:
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::dispatch::BuiltinReposition;
use elevator_core::dispatch::reposition::SpreadEvenly;
fn main() -> Result<(), SimError> {
let sim = SimulationBuilder::new()
.stop(StopId(0), "Ground", 0.0)
.stop(StopId(1), "Top", 10.0)
.elevator(ElevatorConfig::default())
.reposition(SpreadEvenly, BuiltinReposition::SpreadEvenly)
.build()?;
Ok(())
}
The second argument is a BuiltinReposition identifier used for snapshot serialization. Pass the variant that matches your strategy so snapshots can restore it correctly.
Four built-in strategies are available:
| Strategy | Behavior |
|---|---|
SpreadEvenly | Distribute idle cars evenly across stops |
ReturnToLobby | Send idle cars to a configured home stop |
DemandWeighted | Position near stops with historically high demand |
NearestIdle | Keep idle cars where they are (no-op) |
Repositioning is optional. Groups without a registered strategy skip the reposition phase entirely.
DispatchManifest
Your strategy (or game code observing dispatch) receives a DispatchManifest with these convenience methods:
| Method | Returns | Description |
|---|---|---|
waiting_count_at(stop) | usize | Number of riders waiting at a stop |
total_weight_at(stop) | f64 | Total weight of riders waiting at a stop |
has_demand(stop) | bool | Whether a stop has any demand (waiting or riding-to) |
riding_count_to(stop) | usize | Number of riders aboard elevators heading to a stop |
For advanced dispatch (priority-aware, weight-aware, VIP-first), use manifest.waiting_riders_at(stop) to access per-stop rider lists, or manifest.iter_waiting_stops() to iterate all stops with waiting demand. Each entry provides a &[RiderInfo] with the rider’s id, destination, weight, and wait_ticks.
Next steps
- Writing a Custom Dispatch Strategy – full tutorial on the
DispatchStrategytrait - Rider Lifecycle – understand the riders that dispatch is serving
- Events and Metrics – observe dispatch decisions via
ElevatorAssignedevents
Rider Lifecycle
Riders are the demand side of the simulation. A rider is anything that rides an elevator – the library assigns no semantics beyond that. Caller code adds meaning (office tenants, hotel guests, freight crates) via extension storage. This chapter covers the full lifecycle, patience and preferences, access control, and population tracking.
Phase diagram
Every rider moves through a sequence of phases from spawn to final disposition:
stateDiagram-v2
[*] --> Waiting: spawn_rider()
Waiting --> Boarding: Loading phase boards rider
Boarding --> Riding: AdvanceTransient (next tick)
Riding --> Exiting: Loading phase exits rider
Exiting --> Arrived: AdvanceTransient (route complete)
Exiting --> Walking: AdvanceTransient (multi-leg route)
Walking --> Waiting: Teleported to next leg origin
Waiting --> Abandoned: Patience expired / abandon_on_full
Arrived --> Resident: settle_rider()
Abandoned --> Resident: settle_rider()
Resident --> Waiting: reroute()
Arrived --> [*]: despawn_rider()
Abandoned --> [*]: despawn_rider()
Resident --> [*]: despawn_rider()
Phase table
| Phase | Where is the rider? | What triggers the transition? |
|---|---|---|
Waiting | At a stop, in the queue | Loading phase boards the rider when an elevator arrives with open doors |
Boarding | Being loaded into the elevator | AdvanceTransient phase advances to Riding on the next tick |
Riding | Inside the elevator | Loading phase exits the rider when the elevator arrives at their destination |
Exiting | Leaving the elevator | AdvanceTransient: becomes Arrived (route complete), or Walking (multi-leg) |
Walking | Transferring between stops | Teleported immediately to the next leg’s origin, then becomes Waiting |
Arrived | At final destination | Caller decides: settle_rider(), despawn_rider(), or leave in place |
Abandoned | Left the queue at a stop | Patience ran out or abandon_on_full triggered; caller can settle or despawn |
Resident | Parked at a stop, not seeking an elevator | Caller invoked settle_rider() on an Arrived or Abandoned rider |
Each transition emits an event: RiderSpawned, RiderBoarded, RiderExited, RiderAbandoned, RiderSettled, RiderRerouted, RiderDespawned.
Note that Boarding and Exiting are transient – they last exactly one tick. This gives events a clean boundary: the Loading phase sets the phase, and AdvanceTransient resolves it at the start of the next tick.
Patience
Riders can have a patience budget that causes automatic abandonment. Attach a Patience component when spawning:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
let mut sim: Simulation = todo!();
let rider = sim.build_rider(StopId(0), StopId(2))
.unwrap()
.weight(75.0)
.patience(300) // abandon after 300 ticks of waiting
.spawn()
.unwrap();
}
The Patience component tracks max_wait_ticks and waited_ticks. The counter increments only while the rider is in the Waiting phase – ride time on multi-leg routes does not count against the budget. When waited_ticks exceeds max_wait_ticks, the rider transitions to Abandoned and a RiderAbandoned event fires.
Preferences
The Preferences component controls boarding behavior:
| Field | Type | Default | Effect |
|---|---|---|---|
skip_full_elevator | bool | false | Skip a crowded elevator and wait for the next one |
max_crowding_factor | f64 | 0.8 | Maximum load factor (0.0-1.0) the rider will tolerate |
abandon_after_ticks | Option<u32> | None | Abandon after N ticks of waiting (time-triggered) |
abandon_on_full | bool | false | Abandon immediately on first full-car skip (event-triggered) |
The two abandonment paths are independent axes:
abandon_after_ticksis time-triggered – the rider abandons after their wait budget elapses, checked during AdvanceTransient.abandon_on_fullis event-triggered – the rider abandons the moment a full-car skip happens, checked during Loading.
Whichever condition fires first wins. Setting abandon_on_full = true with abandon_after_ticks = None is valid and abandons on the first full-car skip regardless of wait time.
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
let mut sim: Simulation = todo!();
let rider = sim.build_rider(StopId(0), StopId(2))
.unwrap()
.weight(75.0)
.preferences(
Preferences::default()
.with_skip_full_elevator(true)
.with_max_crowding_factor(0.5)
.with_abandon_on_full(true)
)
.spawn()
.unwrap();
}
When skip_full_elevator is true and the load exceeds max_crowding_factor, the rider silently skips the car. A RiderSkipped event fires so caller code or UI can react. The rider remains Waiting for the next car – unless abandon_on_full escalates it to Abandoned.
Access control
The AccessControl component restricts which stops a rider may visit. If a rider’s destination is not in their allowlist, boarding is rejected with RejectionReason::AccessDenied:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
use std::collections::HashSet;
let mut sim: Simulation = todo!();
let lobby: EntityId = todo!();
let executive_floor: EntityId = todo!();
let allowed = HashSet::from([lobby, executive_floor]);
let rider = sim.build_rider(StopId(0), StopId(1))
.unwrap()
.weight(75.0)
.access_control(AccessControl::new(allowed))
.spawn()
.unwrap();
}
Managing arrived riders
Once a rider reaches Arrived or Abandoned, the simulation stops managing them. Caller code decides what happens next using three methods:
sim.settle_rider(id) – transitions an Arrived or Abandoned rider to Resident. Residents are parked at a stop, tracked by the population index, and invisible to dispatch. Use this for occupants who live or work on a floor between elevator trips: a tenant in their office, a hotel guest in their room, a patient on a ward.
sim.reroute(id, route) – replaces the rider’s route. Dispatches on phase: a Waiting rider gets the new route in place; a Resident rider is transitioned back to Waiting and the route is set. Use this for mid-wait plan changes (Waiting riders) or for settled occupants who have a new destination – a tenant heading to lunch, a guest checking out (Resident riders).
sim.despawn_rider(id) – removes the rider from the simulation entirely. Always use this instead of world.despawn() directly – it keeps the stop index consistent.
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
let mut sim: Simulation = todo!();
let rider: RiderId = todo!();
let new_route: Route = todo!();
// Tenant arrived at their floor -- settle them so they hold position
// at the stop without re-entering the dispatch queue.
sim.settle_rider(rider).unwrap();
// Later, the same tenant heads back down. `reroute` accepts a full
// Route and works for both Waiting and Resident riders.
sim.reroute(rider, new_route).unwrap();
}
Population tracking
The simulation maintains a reverse index (RiderIndex) for O(1) per-stop population queries without scanning the full entity list:
| Method | Returns |
|---|---|
sim.waiting_at(stop) | Riders waiting for an elevator at a stop |
sim.residents_at(stop) | Riders settled as residents at a stop |
sim.abandoned_at(stop) | Riders who gave up waiting at a stop |
Each returns an iterator of EntityId values. Use .count() for totals or .collect::<Vec<_>>() to materialize.
These queries are useful for caller-side logic (spawn limits, crowd visualisation), dispatch strategies (demand weighting), and analytics (per-floor breakdowns).
Next steps
- Door Control – understand when riders can board and exit
- Events and Metrics – track rider events and aggregate wait/ride times
- Extensions – attach caller-defined data to riders
Door Control
Doors gate when riders can board and exit. Each elevator has a DoorState finite-state machine that ticks during the Doors phase. This chapter covers the FSM, timing configuration, manual door commands, and the events they produce.
DoorState FSM
Doors cycle through four states:
stateDiagram-v2
Closed --> Opening: Elevator arrives at stop
Opening --> Open: transition ticks elapsed (DoorOpened)
Open --> Closing: hold ticks elapsed
Closing --> Closed: transition ticks elapsed (DoorClosed)
Open --> Open: hold_door() extends dwell
Closing --> Opening: open_door() reopens
Open --> Closing: close_door() forces early close
| State | What is happening |
|---|---|
Closed | Doors fully shut. Elevator may move. |
Opening { ticks_remaining, .. } | Doors in transit. Riders cannot board yet. |
Open { ticks_remaining, .. } | Doors fully open. Riders board and exit during the Loading phase. |
Closing { ticks_remaining } | Doors in transit. No boarding. |
When an elevator arrives at its target stop, the door transitions to Opening. After the transition completes, the elevator enters the Loading phase where riders board and exit. Once the hold timer expires, doors begin closing. After closing completes, the elevator enters Stopped and becomes available for the next dispatch assignment.
Door timing config
Two parameters on ElevatorConfig control door timing:
| Parameter | Default | Description |
|---|---|---|
door_transition_ticks | 5 | Ticks for the opening and closing transitions |
door_open_ticks | 10 | Ticks the doors stay fully open before closing |
A full door cycle takes door_transition_ticks * 2 + door_open_ticks ticks. With defaults, that is 20 ticks per stop visit.
You can change these at runtime:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
let mut sim: Simulation = todo!();
let elev: ElevatorId = todo!();
sim.set_door_transition_ticks(elev, 3).unwrap();
sim.set_door_open_ticks(elev, 15).unwrap();
}
Changes apply on the next door cycle. An in-progress transition keeps its original timing.
Manual door commands
Four methods let game code override automatic door behavior:
| Method | DoorCommand | Effect |
|---|---|---|
sim.open_door(elev) | Open | Open doors now, or on arrival at the next stop |
sim.close_door(elev) | Close | Close doors now, or as soon as loading finishes |
sim.hold_door(elev, ticks) | HoldOpen { ticks } | Extend the open dwell by ticks (cumulative) |
sim.cancel_door_hold(elev) | CancelHold | Cancel any pending hold extension |
Commands are queued on the target elevator and processed at the start of the Doors phase. Commands that are not yet valid (e.g., Open while already opening) stay queued until they become applicable.
Here is a practical example – hold doors for a late arrival, then force them shut:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
let mut sim: Simulation = todo!();
let elev: ElevatorId = todo!();
// A friend is running for the elevator -- hold the doors.
sim.hold_door(elev, 60).unwrap();
// Friend made it aboard. Force the doors closed early.
sim.close_door(elev).unwrap();
}
Door events
| Event | When it fires |
|---|---|
DoorOpened { elevator, tick } | Doors finish opening (transition to Open) |
DoorClosed { elevator, tick } | Doors finish closing (transition to Closed) |
DoorCommandQueued { elevator, command, tick } | A manual command was accepted onto the queue |
DoorCommandApplied { elevator, command, tick } | A queued command took effect |
The Queued/Applied pair is useful for driving UI feedback (button flash, sound effect) without polling the elevator every tick. A command is queued immediately when you call open_door() etc., but may not be applied until a later tick when the door state is compatible.
Interaction with Loading
Riders can only board or exit when the doors are fully open (DoorState::Open). The Loading phase runs after the Doors phase each tick, so the sequence within a single tick is:
- Doors phase ticks the FSM. If
Openingcompletes, the elevator transitions toLoadingphase. - Loading phase checks all elevators in
Loadingphase. One rider action per elevator per tick: exit takes priority over boarding. - When the hold timer expires, doors begin closing. No more boarding until the next door-open cycle.
Since only one rider boards per tick and doors close after door_open_ticks ticks, the maximum riders boarding per stop visit equals door_open_ticks. If riders are queueing up, increase door_open_ticks to let more board per visit.
Next steps
- Movement and Physics – how elevators travel between stops
- Rider Lifecycle – what happens to riders once they board
- Events and Metrics – door events in the broader event system
Movement and Physics
Elevator movement uses a trapezoidal velocity profile – accelerate, cruise at max speed, decelerate to stop precisely at the target. This chapter covers the physics model, per-elevator parameters, position interpolation for rendering, ETA queries, and passing-floor detection.
Trapezoidal velocity profile
Each tick, tick_movement() advances an elevator’s position and velocity through three regions:
graph LR
A[Accelerate] --> B[Cruise]
B --> C[Decelerate]
C --> D[Arrived]
- Accelerate – speed increases by
acceleration * dtper tick until reachingmax_speed. - Cruise – speed holds at
max_speedwhile the remaining distance exceeds the braking distance. - Decelerate – speed decreases by
deceleration * dtper tick, arriving at the target with velocity near zero.
For short trips where the elevator cannot reach max speed, the profile becomes triangular: accelerate then immediately decelerate. The system handles this automatically – no special configuration needed.
Per-elevator physics parameters
Physics are configured per-elevator on ElevatorConfig:
| Parameter | Type | Default | Description |
|---|---|---|---|
max_speed | Speed | 2.0 | Maximum speed magnitude |
acceleration | Accel | 1.5 | Rate of acceleration (positive) |
deceleration | Accel | 2.0 | Rate of deceleration (positive) |
All three are stored on the Elevator component at runtime, so different elevators (express vs. local, freight vs. passenger) can have different physics in the same simulation.
Braking distance
The braking_distance() function computes the distance required to stop from a given velocity:
#![allow(unused)]
fn main() {
use elevator_core::movement::braking_distance;
let distance = braking_distance(2.5, 1.0); // v=2.5, decel=1.0
// distance = v^2 / (2 * a) = 3.125
}
This is useful for custom dispatch strategies that want to consider opportunistic stops – whether an elevator passing a floor can brake in time. The Simulation also exposes sim.braking_distance(elev) to get the current braking distance for a specific elevator without reimplementing the physics.
Position interpolation
Game renderers typically run at a higher framerate than the simulation tick rate. To produce smooth motion between ticks, use position_at() with an interpolation alpha:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
let sim: Simulation = todo!();
let elev: EntityId = todo!();
// alpha = 0.0 is the position at tick start, 1.0 at tick end.
let p_start = sim.position_at(elev, 0.0).unwrap();
let p_mid = sim.position_at(elev, 0.5).unwrap();
let p_end = sim.position_at(elev, 1.0).unwrap();
}
A typical render loop at 4x the sim rate samples at alpha = 0.0, 0.25, 0.5, 0.75:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
let mut sim: Simulation = todo!();
let elev: EntityId = todo!();
sim.step();
// Render 4 sub-frames per tick.
for i in 0..4 {
let alpha = i as f64 / 4.0;
let y = sim.position_at(elev, alpha).unwrap();
// Set camera or sprite position to y.
}
}
The current velocity is available via sim.velocity(elev), which returns Option<f64> – the signed value (positive = upward), or None if the entity isn’t an elevator.
ETA queries
Two methods estimate arrival times for UI countdown displays and dispatch logic:
sim.eta(elevator, stop) – returns a Duration estimating how long until elevator reaches stop, based on its current queue and trapezoidal physics. The estimate assumes no mid-trip changes (door commands, new riders, dispatch reassignment).
sim.best_eta(stop, direction) – returns the (EntityId, Duration) of the elevator with the shortest ETA to a stop in the given direction, or None if no eligible car has that stop queued.
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
let sim: Simulation = todo!();
let elev: ElevatorId = todo!();
let lobby: EntityId = todo!();
// How long until this specific elevator reaches the lobby?
match sim.eta(elev, lobby) {
Ok(duration) => println!("ETA: {duration:.1?}"),
Err(e) => println!("Cannot estimate: {e}"),
}
// Which elevator will reach the lobby soonest (going down)?
if let Some((car, eta)) = sim.best_eta(lobby, Direction::Down) {
println!("Car {car:?} arriving in {eta:.1?}");
}
}
ETA queries can fail with EtaError – the elevator might not be headed to that stop, or it might be in an excluded service mode. See Error Handling for details.
PassingFloor events
When an elevator passes a stop without stopping, a PassingFloor event fires:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn handle(event: Event) {
if let Event::PassingFloor { elevator, stop, moving_up, tick } = event {
let _ = (elevator, stop, moving_up, tick);
}
}
}
This is useful for:
- Playing a “ding” or updating a floor indicator in your game UI
- Custom dispatch strategies that react to passing traffic
- Analytics on which floors see the most pass-throughs
SortedStops resource
Passing-floor detection uses the SortedStops resource internally – a sorted list of (position, EntityId) pairs that enables O(log n) binary search to find which stops an elevator crossed during a tick. This is an implementation detail; you do not interact with SortedStops directly.
Next steps
- Door Control – what happens when an elevator arrives at a stop
- Dispatch Strategies – how elevators decide where to go
- Events and Metrics –
ElevatorArrived,PassingFloor, and other movement events
Events and Metrics
Every significant moment in the simulation – 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 your simulation.
Event system
Elevator events
| Event | When it fires |
|---|---|
ElevatorDeparted { elevator, from_stop, tick } | An elevator leaves a stop |
ElevatorArrived { elevator, at_stop, tick } | An elevator arrives at a stop |
ElevatorAssigned { elevator, stop, tick } | Dispatch assigns an elevator to a stop |
ElevatorIdle { elevator, at_stop: Option, tick } | An elevator became idle (at_stop is None if not at a stop) |
ElevatorRepositioning { elevator, to_stop, tick } | An idle elevator begins repositioning |
ElevatorRepositioned { elevator, at_stop, tick } | An elevator completed repositioning |
DoorOpened { elevator, tick } | Doors finish opening |
DoorClosed { elevator, tick } | Doors finish closing |
DoorCommandQueued { elevator, command, tick } | A manual door command was accepted |
DoorCommandApplied { elevator, command, tick } | A queued door command took effect |
PassingFloor { elevator, stop, moving_up, tick } | An elevator passes a stop without stopping |
MovementAborted { elevator, brake_target, tick } | abort_movement was called mid-flight; the car will brake to brake_target without opening doors |
CapacityChanged { elevator, current_load, capacity, tick } | An elevator’s load changed |
DirectionIndicatorChanged { elevator, going_up, going_down, tick } | Direction lamps changed |
DestinationQueued { elevator, stop, tick } | A stop was pushed onto the destination queue |
ServiceModeChanged { elevator, from, to, tick } | Elevator service mode changed |
ElevatorUpgraded { elevator, field, old, new, tick } | A runtime upgrade was applied (e.g., set_max_speed) |
ManualVelocityCommanded { elevator, target_velocity, tick } | A manual velocity command was issued |
Rider events
| Event | When it fires |
|---|---|
RiderSpawned { rider, origin, destination, tag, tick } | A new rider appears at a stop |
RiderBoarded { rider, elevator, tag, tick } | A rider enters an elevator |
RiderExited { rider, elevator, stop, tag, tick } | A rider exits at their destination |
RiderRejected { rider, elevator, reason, context, tag, tick } | A rider was refused boarding |
RiderAbandoned { rider, stop, tag, tick } | A rider gave up waiting |
RiderSkipped { rider, elevator, at_stop, tag, tick } | A rider skipped a crowded car (may still board the next) |
RiderEjected { rider, elevator, stop, tag, tick } | A rider was ejected (elevator disabled) |
RiderSettled { rider, stop, tag, tick } | A rider settled as a resident |
RiderDespawned { rider, tag, tick } | A rider was removed from the simulation |
RiderRerouted { rider, new_destination, tag, tick } | A rider was rerouted to a new destination |
Every rider-bearing variant carries a tag: u64 that mirrors the rider’s opaque consumer tag at emit time (see set_rider_tag). It is 0 for untagged riders and is sampled before the rider is freed on RiderExited / RiderDespawned, so consumers can correlate the event with their own object space without an extra lookup.
Topology events
| Event | When it fires |
|---|---|
StopAdded { stop, line, group, tick } | A stop was added at runtime |
ElevatorAdded { elevator, line, 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, tag, 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 moved between groups |
ElevatorReassigned { elevator, old_line, new_line, tick } | An elevator moved between lines |
StopRemoved { stop, tick } | A stop was removed |
ElevatorRemoved { elevator, line, group, tick } | An elevator was removed |
Dispatch events
| Event | When it fires |
|---|---|
HallButtonPressed { stop, direction, tick } | First press per (stop, direction) |
HallCallAcknowledged { stop, direction, tick } | Ack-latency window elapsed |
HallCallCleared { stop, direction, car, tick } | Assigned car opened doors at stop |
CarButtonPressed { car, floor, rider: Option, tag: Option, tick } | Floor button pressed inside a car |
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::*;
use elevator_core::__doctest_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::ElevatorArrived { elevator, at_stop, tick } => {
println!("[{tick}] {elevator:?} arrived at {at_stop:?}");
}
_ => {}
}
}
}
}
You can drain after every tick, every N ticks, or only when you need to – events accumulate until drained. The metrics system processes events independently, so draining does not affect metric calculations.
If you never drain, the buffer grows unbounded. In long-running simulations, drain at least periodically.
The repository ships examples/events_loop.rs as a focused tutorial showing this pattern end-to-end – a 3-stop building, three riders, and a match arm over RiderBoarded / RiderExited / DoorOpened printing a short narrative.
Event ordering guarantees
- Within a tick: events fire in phase order (AdvanceTransient, Dispatch, Reposition, Movement, Doors, Loading, Metrics). Events from a later phase always appear later in the drained vec.
- Across ticks: events from tick N precede events from tick N+1. Every event carries its
tickfield for timeline reconstruction. - Within a phase: ordering is stable but not part of the public contract. Do not rely on which elevator’s events come first within the same phase.
- Pair invariants:
RiderBoardedalways precedes the matchingRiderExitedfor the same rider.DoorOpenedalways precedesDoorClosedfor the same elevator at a given stop.
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::*;
use elevator_core::__doctest_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!("p95 wait time: {} ticks", m.p95_wait_time());
println!("Throughput: {} riders/window", m.throughput());
println!("Total delivered: {}", m.total_delivered());
println!("Total abandoned: {}", m.total_abandoned());
println!("Abandonment rate: {:.1}%", m.abandonment_rate() * 100.0);
println!("Total distance: {:.1} units", m.total_distance());
}
}
Metric reference
| Metric | Description |
|---|---|
avg_wait_time() | Average ticks from spawn to board, across all riders that boarded |
avg_ride_time() | Average ticks from board to exit, across all delivered riders |
max_wait_time() | Longest wait observed (ticks) |
p95_wait_time() | 95th-percentile wait over the most recent boardings (ticks). CIBSE and community benchmarks score against percentiles rather than avg/max. |
percentile_wait_time(p) | Wait-time percentile (0.0..=100.0) over the retained sample window |
wait_sample_count() | Number of wait samples currently retained in the ring buffer |
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 |
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 each tick. They are always available and always reflect the latest tick, regardless of whether you drain events.
Compact Display output
Metrics implements Display for a one-line summary suitable for HUDs and logs:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &Simulation) {
println!("{}", sim.metrics());
// Output: "42 delivered, avg wait 87.3t, 65% util"
}
}
Inspection queries
The Simulation exposes read-only query helpers for game UIs and dispatch logic:
| Method | Returns |
|---|---|
sim.idle_elevator_count() | Count of elevators currently idle (excludes disabled) |
sim.elevators_in_phase(phase) | Count of elevators in a given phase (excludes disabled) |
sim.elevator_load(id) | Current total weight aboard an elevator |
sim.elevator_move_count(id) | Per-elevator count of rounded-floor transitions |
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_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 resets to Idle on disable, but they should not appear as “available” in game UIs.
Tagged metrics
For per-zone or per-label breakdowns, tag entities with string labels and query metrics scoped to those tags.
Tagging entities
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
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 lobby = sim.stop_entity(StopId(0)).unwrap();
sim.tag_entity(lobby, "zone:lobby")?;
// Riders auto-inherit tags from their origin stop when spawned.
let rider = sim.spawn_rider(StopId(0), StopId(1), 75.0)?;
// rider automatically has "zone:lobby"
// Manual tagging is also supported.
sim.tag_entity(rider.entity(), "priority:vip")?;
Ok(())
}
Querying tagged metrics
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &Simulation) {
if let Some(m) = sim.metrics_for_tag("zone:lobby") {
println!("Lobby avg wait: {:.1} ticks", m.avg_wait_time());
println!("Lobby delivered: {}", m.total_delivered());
println!("Lobby abandoned: {}", m.total_abandoned());
}
}
}
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.
Converting ticks to seconds
Metrics are reported in ticks. Convert to wall-clock seconds via the TimeAdapter:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_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");
}
}
The default tick rate is 60 ticks per second. Configure it via ticks_per_second in the simulation config.
Next steps
- Error Handling – understand
RiderRejectedreasons and other error types - Rider Lifecycle – the phases behind rider events
- Extensions – custom event channels for game-specific events
Error Handling
The simulation uses a single error enum, SimError, for all fallible operations. This chapter covers the error variants, rider rejection reasons, and practical patterns for handling failures in game code.
SimError
SimError covers both configuration validation and runtime failures. It implements Display and std::error::Error, so it works with ?, anyhow, eyre, and other error-handling crates.
Configuration errors
| Variant | When it occurs |
|---|---|
InvalidConfig { field, reason } | A config field fails validation during SimulationBuilder::build() |
The field string identifies which config parameter is invalid, and reason explains why. All config validation happens at construction time – if build() succeeds, the simulation is in a valid state.
Entity lookup errors
| Variant | When it occurs |
|---|---|
EntityNotFound(EntityId) | A referenced entity does not exist in the world |
StopNotFound(StopId) | A StopId from config does not map to any entity |
GroupNotFound(GroupId) | A referenced group does not exist |
LineNotFound(EntityId) | A line entity was not found |
NotAnElevator(EntityId) | An operation expected an elevator but got a different entity |
NotAStop(EntityId) | An operation expected a stop but got a different entity |
These are the most common runtime errors. They typically indicate a stale EntityId (the entity was despawned) or a config mismatch (using a StopId that was never registered).
Rider state errors
| Variant | When it occurs |
|---|---|
WrongRiderPhase { rider, expected, actual } | A lifecycle operation was called on a rider in the wrong phase |
RiderHasNoStop(EntityId) | A rider has no current_stop when one is required |
EmptyRoute | A route with no legs was provided |
For example, calling sim.settle_rider(id) on a rider in Waiting phase returns WrongRiderPhase – settle requires Arrived or Abandoned.
Routing errors
| Variant | When it occurs |
|---|---|
NoRoute { origin, destination, .. } | No group serves both origin and destination stops |
AmbiguousRoute { origin, destination, groups } | Multiple groups serve both stops – caller must specify which |
RouteOriginMismatch { expected_origin, route_origin } | A route’s origin does not match the rider’s current position |
NoRoute and AmbiguousRoute are returned by spawn_rider() and build_rider(). In multi-group buildings, use build_rider() with an explicit group to resolve ambiguity.
Topology errors
| Variant | When it occurs |
|---|---|
LineDoesNotServeStop { line_or_car, stop } | An elevator’s line cannot reach the target stop |
ElevatorDisabled(EntityId) | An operation was attempted on a disabled elevator |
WrongServiceMode { entity, expected, actual } | An elevator is in an incompatible service mode |
HallCallNotFound { stop, direction } | No hall call exists at the given stop and direction |
Snapshot errors
| Variant | When it occurs |
|---|---|
SnapshotVersion { saved, current } | Snapshot was produced by a different library version |
SnapshotFormat(String) | Snapshot bytes are malformed |
UnresolvedCustomStrategy { name, group } | A custom dispatch strategy in the snapshot could not be resolved |
RejectionReason
When a rider cannot board an elevator, a RiderRejected event fires with a typed RejectionReason:
| Reason | Description |
|---|---|
OverCapacity | The rider’s weight would exceed the elevator’s remaining capacity |
PreferenceBased | The rider’s boarding preferences prevented boarding (crowding threshold) |
AccessDenied | The rider lacks access to the destination stop, or the elevator cannot serve it |
RejectionContext
Every RiderRejected event includes a RejectionContext with the numeric details behind the decision:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
use ordered_float::OrderedFloat;
let context = RejectionContext {
attempted_weight: OrderedFloat(80.0), // weight the rider tried to add
current_load: OrderedFloat(750.0), // elevator's load at rejection time
capacity: OrderedFloat(800.0), // elevator's maximum weight capacity
};
let _ = context;
}
RejectionContext implements Display for game-friendly feedback:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
use ordered_float::OrderedFloat;
let context = RejectionContext {
attempted_weight: OrderedFloat(80.0),
current_load: OrderedFloat(750.0),
capacity: OrderedFloat(800.0),
};
// "over capacity by 30.0kg (750.0/800.0 + 80.0)"
println!("{}", context);
}
EtaError
ETA queries (sim.eta(), sim.best_eta()) use a separate error type because they fail for different reasons than general simulation operations:
| Variant | When it occurs |
|---|---|
NotAnElevator(EntityId) | The queried entity is not an elevator |
NotAStop(EntityId) | The queried entity is not a stop |
StopNotQueued { elevator, stop } | The stop is not in the elevator’s destination queue |
ServiceModeExcluded(EntityId) | The elevator’s service mode excludes it from dispatch queries |
StopVanished(EntityId) | A stop in the route disappeared during calculation |
NoCarAssigned(EntityId) | No car has been assigned to serve the hall call at this stop |
Handling spawn failures
The most common error path in game code is spawning riders. Here are the key failures and how to handle them:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
let mut sim: Simulation = todo!();
match sim.spawn_rider(StopId(0), StopId(5), 75.0) {
Ok(rider) => {
// Rider spawned successfully.
}
Err(SimError::StopNotFound(id)) => {
// StopId doesn't exist in config. Check your stop setup.
eprintln!("Unknown stop: {id}");
}
Err(SimError::NoRoute { origin, destination, .. }) => {
// No group connects these two stops.
eprintln!("No route from {origin:?} to {destination:?}");
}
Err(SimError::AmbiguousRoute { groups, .. }) => {
// Multiple groups serve both stops. Use build_rider()
// with an explicit group instead.
eprintln!("Ambiguous: served by {groups:?}");
}
Err(e) => {
eprintln!("Spawn failed: {e}");
}
}
}
Reacting to rejections
Rider rejections are not Rust errors – they are events. The simulation continues normally; the rejected rider stays in their current phase (usually Waiting). React to rejections by draining events:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &mut Simulation) {
sim.step();
for event in sim.drain_events() {
if let Event::RiderRejected { rider, elevator, reason, context, tick, .. } = event {
match reason {
RejectionReason::OverCapacity => {
// Show "elevator full" indicator in game UI.
if let Some(ctx) = &context {
println!("[{tick}] {rider:?} rejected from {elevator:?}: {ctx}");
}
}
RejectionReason::AccessDenied => {
// Flash "access denied" on the panel.
}
RejectionReason::PreferenceBased => {
// Rider chose to skip -- animate them stepping back.
}
_ => {}
}
}
}
}
}
Best practices
- Always handle
Results fromspawn_riderandbuild_rider. These are the most likely to fail in dynamic scenarios where stops or groups change at runtime. - Use
build_rider()in multi-group buildings to avoidAmbiguousRouteby specifying the group explicitly. - Check
is_elevator()/is_stop()before calling entity-specific methods if you are working with mixedEntityIdcollections. - Treat
RiderRejectedas a normal game event, not an error. The simulation handles it gracefully; your game just needs to decide how to present it. - Log
SimErrorin debug builds. TheDisplayimpl produces clear, actionable messages that point directly at the problem.
Next steps
- Rider Lifecycle – understand the phases that trigger rejection and abandonment
- Events and Metrics – the full event system including rejection events
- Configuration – avoid
InvalidConfigerrors by understanding validation rules
Writing a Custom Dispatch Strategy
The built-in strategies (SCAN, LOOK, NearestCar, ETD, Destination) 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 a quick overview, the Dispatch Strategies chapter has one.
How dispatch works
Strategies express preference as a cost on each (car, stop) pair. The dispatch system collects those costs into a matrix and solves the optimal assignment across the whole group, guaranteeing that two cars are never sent to the same hall call. Cars left unassigned fall through to fallback for per-car policy (idle, park, etc.). (The solver is the Hungarian / Kuhn-Munkres algorithm – you don’t need to know how it works, just that it finds the globally optimal matching.)
flowchart LR
A["DispatchStrategy::rank()"] -->|"scores (car, stop) pairs"| B["Cost Matrix"]
B -->|"O(n^3) solver"| C["Hungarian Assignment"]
C -->|"unassigned cars"| D["fallback()"]
C -->|"assigned cars"| E["GoToStop decisions"]
D --> F["Idle / Park / custom"]
The trait surface
pub trait DispatchStrategy: Send + Sync {
/// Pre-pass hook with mutable world access. Used by sticky strategies
/// (e.g. destination dispatch) to commit rider -> car assignments.
fn pre_dispatch(
&mut self,
_group: &ElevatorGroup,
_manifest: &DispatchManifest,
_world: &mut World,
) { /* default: no-op */ }
/// Per-car setup called once before any `rank` calls for this car.
/// Strategies with per-car state (sweep direction, queue pointers)
/// refresh it here so `rank` is order-independent over stops.
fn prepare_car(
&mut self,
_car: EntityId,
_car_position: f64,
_group: &ElevatorGroup,
_manifest: &DispatchManifest,
_world: &World,
) { /* default: no-op */ }
/// Score sending `car` to `stop`. Lower is better. `None` marks
/// the pair unavailable (capacity limits, wrong-direction, sticky).
/// Must return a finite, non-negative value when `Some`.
fn rank(&self, ctx: &RankContext<'_>) -> Option<f64>;
/// Decide what a car should do when the assignment phase couldn't
/// give it a stop (no demand or all candidate ranks were `None`).
fn fallback(
&mut self,
_car: EntityId,
_car_position: f64,
_group: &ElevatorGroup,
_manifest: &DispatchManifest,
_world: &World,
) -> DispatchDecision { DispatchDecision::Idle }
/// Clean up per-elevator state when a car leaves the group.
/// Strategies with internal `HashMap<EntityId, _>` state must
/// remove the entry here -- otherwise the map grows unbounded.
fn notify_removed(&mut self, _elevator: EntityId) { /* default: no-op */ }
}
RankContext bundles the per-call arguments into a single struct:
pub struct RankContext<'a> {
pub car: EntityId,
pub car_position: f64,
pub stop: EntityId,
pub stop_position: f64,
pub group: &'a ElevatorGroup,
pub manifest: &'a DispatchManifest,
pub world: &'a World,
}
Only rank is required. The default fallback returns Idle; the other hooks exist for strategies that need them.
Step 1 – The simplest possible strategy
“Nearest-car by distance, favoring stops with more waiting riders.”
#![allow(unused)]
fn main() {
use elevator_core::dispatch::{DispatchStrategy, RankContext};
struct BusyStopNearest;
impl DispatchStrategy for BusyStopNearest {
fn rank(&self, ctx: &RankContext<'_>) -> Option<f64> {
let distance = (ctx.car_position() - ctx.stop_position()).abs();
let waiting = ctx.manifest.waiting_count_at(ctx.stop) as f64;
// Subtract a crowding bonus so busier stops look cheaper. Clamp
// so the solver never sees a negative cost.
Some((distance - waiting).max(0.0))
}
}
}
What this gets you automatically:
- Coordination across cars – the Hungarian solver never sends two cars to the same hall call.
- Direction indicators driven by the
GoToStopdecision vs. car position. DestinationQueuemanagement handled by later phases – you don’t touch it.- Dispatch events (
ElevatorAssigned,ElevatorIdle,DirectionIndicatorChanged) emit automatically.
Returning None from rank excludes a (car, stop) pair entirely – use it for capacity limits, wrong-direction stops, or restricted stops. When every candidate stop returns None (or there are no demanded stops at all), the dispatcher calls fallback, which defaults to Idle.
Step 2 – Per-car state with prepare_car
Strategies whose ranking depends on per-car state (a sweep direction, a queue pointer, a cached priority) should refresh that state in prepare_car. The framework calls it once per car per pass, before any rank calls for that car, so the subsequent rank results are independent of the order the dispatcher iterates stops.
The built-in ScanDispatch uses this hook to decide whether the car’s sweep direction should flip for the current pass. That decision depends on whole-group demand, so doing it inside rank would give different answers depending on which stop was scored first.
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use elevator_core::dispatch::{
DispatchManifest, DispatchStrategy, ElevatorGroup, RankContext,
};
use elevator_core::entity::EntityId;
use elevator_core::world::World;
struct DirectionalDispatch {
/// Tracks the preferred sweep direction per car.
sweep_up: HashMap<EntityId, bool>,
}
impl DispatchStrategy for DirectionalDispatch {
fn prepare_car(
&mut self,
car: EntityId,
car_position: f64,
group: &ElevatorGroup,
manifest: &DispatchManifest,
world: &World,
) {
// Decide sweep direction based on where demand is heaviest
// relative to this car. This runs once per car, before any
// rank() calls for that car.
let demand_above = group.stop_entities().iter().filter(|&&s| {
manifest.waiting_count_at(s) > 0
&& world.stop_position(s).map_or(false, |p| p > car_position)
}).count();
let demand_below = group.stop_entities().iter().filter(|&&s| {
manifest.waiting_count_at(s) > 0
&& world.stop_position(s).map_or(false, |p| p < car_position)
}).count();
self.sweep_up.insert(car, demand_above >= demand_below);
}
fn rank(&self, ctx: &RankContext<'_>) -> Option<f64> {
let going_up = self.sweep_up.get(&ctx.car).copied().unwrap_or(true);
let is_ahead = if going_up {
ctx.stop_position() >= ctx.car_position()
} else {
ctx.stop_position() <= ctx.car_position()
};
if is_ahead {
Some((ctx.car_position() - ctx.stop_position()).abs())
} else {
// Penalize stops behind the sweep direction.
Some((ctx.car_position() - ctx.stop_position()).abs() + 1000.0)
}
}
fn notify_removed(&mut self, elevator: EntityId) {
self.sweep_up.remove(&elevator);
}
}
}
Step 3 – Carrying state, and the notify_removed contract
If your strategy tracks something per elevator (direction history, last-served stop, priority bookkeeping), it owns a HashMap<EntityId, _>. That map must be cleaned up when an elevator is removed or reassigned across groups, or it grows forever.
The framework calls notify_removed(elevator) on the group’s dispatcher whenever:
Simulation::remove_elevator(id)is called, ORSimulation::reassign_elevator_to_line(id, new_line)moves an elevator across groups (same-group moves don’t firenotify_removedbecause the dispatcher still owns the elevator).
Forgetting to implement this is the most common correctness bug in custom strategies. ScanDispatch and LookDispatch both use it to evict direction entries.
use std::collections::HashMap;
#[derive(Default)]
struct PriorityDispatch {
/// Per-elevator cooldown -- once this elevator served a priority stop,
/// suppress priority preference for N ticks so non-priority riders
/// aren't starved.
cooldown_ticks: HashMap<EntityId, u64>,
}
impl DispatchStrategy for PriorityDispatch {
fn rank(/* ... */) -> Option<f64> { /* ... */ }
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, ::Rsr, ::Destination); 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 your custom strategy doesn’t identify itself via builtin_id (see below), the sim silently records BuiltinStrategy::Scan instead – the snapshot name is wrong and the factory never gets called on restore.
The canonical pattern uses two hooks on the DispatchStrategy trait:
builtin_id()advertises the snapshot name. Override it to returnBuiltinStrategy::Custom("name")soSimulation::new/ the builder /set_dispatchall record the right identity, regardless of which entry point the caller uses.snapshot_config()/restore_config()(optional) round-trip any tunable configuration. Without overriding, the restored instance runs with whatever defaults the factory produces. Override them if your strategy has runtime-tunable weights or other state that should survive a save.
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::dispatch::{BuiltinStrategy, DispatchStrategy, RankContext};
use elevator_core::snapshot::WorldSnapshot;
use serde::{Deserialize, Serialize};
const PRIORITY_NAME: &str = "priority";
#[derive(Default, Serialize, Deserialize)]
struct PriorityDispatch {
urgency_boost: f64,
}
impl DispatchStrategy for PriorityDispatch {
// Real implementations score against `ctx`; see `BusyStopNearest` above.
fn rank(&self, _ctx: &RankContext<'_>) -> Option<f64> { Some(0.0) }
fn builtin_id(&self) -> Option<BuiltinStrategy> {
// Identify the strategy to the snapshot layer. Keep this name
// stable across releases -- changing it breaks old saves.
Some(BuiltinStrategy::Custom(PRIORITY_NAME.into()))
}
fn snapshot_config(&self) -> Option<String> {
ron::to_string(self).ok()
}
fn restore_config(&mut self, serialized: &str) -> Result<(), String> {
let restored: Self = ron::from_str(serialized).map_err(|e| e.to_string())?;
*self = restored;
Ok(())
}
}
fn run(snapshot: WorldSnapshot) -> Result<(), SimError> {
// `Simulation::new` and the builder consult `builtin_id` so you can
// drop the strategy in without a second id argument -- the snapshot
// records `BuiltinStrategy::Custom("priority")` automatically.
let _sim = SimulationBuilder::new()
.stop(StopId(0), "Ground", 0.0)
.stop(StopId(1), "Top", 10.0)
.elevator(ElevatorConfig::default())
.dispatch(PriorityDispatch::default())
.build()?;
// When restoring, the factory maps names back to strategy instances.
// `restore_config` replays `urgency_boost` onto the new instance.
use elevator_core::snapshot::RestoreOptions;
let sim = snapshot.restore(RestoreOptions::with_factory(&|name: &str| -> Option<Box<dyn DispatchStrategy>> {
match name {
PRIORITY_NAME => Some(Box::new(PriorityDispatch::default())),
// Return `None` for unknown names -- the restore records a
// `SnapshotDanglingReference` event and falls back to
// `ScanDispatch` rather than panicking.
_ => None,
}
}))?;
let _ = sim;
Ok(())
}
}
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 work well:
Unit-test through dispatch::assign in isolation. Construct a minimal World, an ElevatorGroup, and a DispatchManifest, then run one assignment pass. This exercises the Hungarian matching and fallback path end-to-end, so the test reflects real runtime behavior. See crates/elevator-core/src/tests/dispatch_tests.rs for the helper pattern (test_world(), test_group(), spawn_elevator(), add_demand()).
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 full tick loop – e.g., a strategy that excludes every (car, stop) pair it shouldn’t, or one whose prepare_car mutation leaves stale state between passes.
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
struct BusyStopNearest;
impl DispatchStrategy for BusyStopNearest {
fn rank(&self, _ctx: &RankContext<'_>) -> Option<f64> { Some(0.0) }
}
#[test]
fn custom_strategy_assigns_nearest_car() {
let mut sim = SimulationBuilder::new()
.stop(StopId(0), "Ground", 0.0)
.stop(StopId(1), "Top", 10.0)
.elevator(ElevatorConfig::default())
.dispatch(BusyStopNearest)
.build()
.unwrap();
sim.spawn_rider(StopId(0), StopId(1), 75.0).unwrap();
sim.step();
let events = sim.drain_events();
assert!(events.iter().any(|e| matches!(e, Event::ElevatorAssigned { .. })));
}
}
See Testing Your Simulation for broader testing patterns including snapshot round-trips and deterministic replay.
Performance considerations
rankruns O(cars x stops) per tick per group, and the Hungarian solver itself is O(n^3) in the group size. At 60 ticks/second and a realistic group (20 cars, 50 stops), that’s millions ofrankcalls per simulated minute – keep the hot path allocation-free andmanifestlookups cheap.SmallVec<[T; N]>is already the storage choice in the built-in strategies for intermediate partitions. If your strategy partitions cars or stops, consider the same.- The
DispatchManifestis immutable – never try to mutate demand from insiderank. If you need per-rider state across ticks, store it in your strategy. - Avoid iterating
HashMap<EntityId, _>in the hot path – order is nondeterministic. UseBTreeMapor sort keys before iteration.
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 – 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.
- Events and Metrics – what dispatch emits and how to consume it for debugging.
Loop Lines
Closed-loop transit topologies – people-movers, gondolas, monorails, airport pedways – aren’t elevator shafts. They’re one-way cycles where every served stop is reachable forward through the line, and “up vs. down” doesn’t apply. The loop_lines cargo feature adds a LineKind::Loop variant that captures this topology end-to-end: cyclic position math, headway-clamped multi-car ordering, two dispatch strategies (LoopSweep and LoopSchedule), and the FSM adaptations that keep Loop cars patrolling continuously without ever entering Idle.
The feature is off by default. Hosts that want to load Loop scenarios opt in via their Cargo.toml:
elevator-core = { path = "../elevator-core", features = ["loop_lines"] }
The shipped elevator-bevy, elevator-tui, and elevator-wasm hosts enable the feature out of the box. elevator-ffi and elevator-gdext expose loop_lines as an opt-in cargo feature; enable it at build time when shipping a Unity / GameMaker / .NET / Godot integration that needs the Loop topology query surface (ev_sim_is_loop, ev_sim_loop_* / is_loop, loop_*).
When to use a loop
Pick LineKind::Loop whenever the physical layout is a closed cycle and the line is one-way. Examples:
- Airport people-movers and parking shuttles
- Theme-park monorails and gondolas
- Mining haul-truck circuits
- Conveyor-style horizontal transport between two towers
If “up” and “down” are meaningful on the line, it’s a Linear shaft; use the default topology. Mixing Loop and Linear lines in the same group is rejected at construction – see Group homogeneity.
Topology
graph LR
N["North (0.0)"] --> E["East (25.0)"]
E --> S["South (50.0)"]
S --> W["West (75.0)"]
W --> N
A LineKind::Loop is configured with two fields:
kind: Some(Loop(
circumference: 100.0,
min_headway: 8.0,
)),
circumference– total path length of the loop, in the same distance units as stops. Positions on the line are normalised modulocircumference, so a car at position125.0on a 100-unit loop is treated as position25.0.min_headway– minimum cyclic arc between consecutive cars. Construction validatesmax_cars * min_headway <= circumference, so a misconfigured loop that couldn’t fit every car at full headway is rejected up front.
Stops on a Loop are listed by ID under serves. Position determines cyclic order; the lowest-position stop isn’t special. Stops sharing a position on the same Loop are rejected (cyclic order would be ambiguous).
Group homogeneity
A group is either all-Linear or all-Loop. Mixing the topologies in one group is rejected at construction:
error: group 0 mixes Loop and Linear lines; groups must be homogeneous
The dispatch and reposition strategies that drive a group don’t compose across topologies. Loop strategies (LoopSweep, LoopSchedule) reject Linear ranking; Linear strategies (Scan, Look, etc.) reject Loop semantics. Splitting the lines into separate groups keeps each group’s strategy honest.
Loop groups additionally:
- Reject reposition strategies (Loop cars never enter
Idle, so there’s nothing to reposition). - Reject every dispatch strategy except
LoopSweepandLoopSchedule.
Dispatch strategies
Two dispatch strategies are available for Loop groups:
LoopSweep – call-driven patrol
Every Loop car patrols forward continuously, boarding every eligible rider at every served stop. Dwell at each stop tracks rider load via the per-car door_open_ticks – a stop with many waiters takes longer than an empty one. This is the default choice for “every car serves everyone, every lap” scenarios.
GroupConfig(
id: 0,
name: "Loop Service",
lines: [1],
dispatch: LoopSweep,
),
LoopSchedule – fixed-dwell timetable
Every Loop car spends a uniform dwell_ticks at every stop, producing a predictable timetable. Useful for people-mover lines, gondolas, and timetabled shuttle services where consistent cadence matters more than load-shaped dwell.
LoopSchedule also runs hold-recovery: when a car arrives at a stop within target_headway_ticks of the preceding arrival, it extends its dwell by min(target_headway_ticks - gap, hold_cap_ticks) to recover the schedule. The cap prevents a stuck leader from freezing the follower indefinitely.
#![allow(unused)]
fn main() {
use elevator_core::dispatch::LoopScheduleDispatch;
// 30-tick dwell, 600-tick target headway, 120-tick cap.
let schedule = LoopScheduleDispatch::new(30, 600, 120);
}
Hold-recovery never speeds a car up – it can only delay followers running ahead of their schedule slot. The no-overtake invariant on Loop lines is preserved.
Loop-aware FSM adaptations
Several pieces of the engine behave differently on Loop lines:
- No
Idlephase: a Loop car constructed inIdleis kickstarted toMovingToStop(forward_next_stop)at the start of the next dispatch tick, and the door FSM hands the car straight fromDoorClosingtoMovingToStop(next)rather thanStopped. Loop cars patrol continuously. - Direction indicator: Loop cars report
Direction::Forwardinstead ofUp/Down/Either. Thegoing_up/going_downlamps are explicitly cleared andgoing_forwardis set true. - Boarding gate bypassed: the linear
(dest_pos > stop_pos) -> requires going_upfilter is meaningless on a cycle. Loop cars board every rider eligible by capacity and route. - Reposition no-op: even with a (now-rejected) reposition strategy installed, the reposition phase explicitly skips Loop cars.
OutOfServicerejected with followers: putting a Loop car intoOutOfServicewould freeze every car behind it on the loop. Construction rejects the transition unless the car has zero followers (single-car loops are allowed).
Example: the shipped demo
assets/config/loop_demo.ron ships a 4-stop, 2-car loop:
Stops: North (0) East (25) South (50) West (75)
Loop: circumference 100, min_headway 8
Cars: Car 1 starts at North, Car 2 starts at South
Dispatch: LoopSweep (call-driven)
Spawning: ~1 rider/sec on the demo's 60 Hz tick rate
Run it from the Bevy host:
cargo run -- assets/config/loop_demo.ron
Both cars patrol forward indefinitely, board waiting riders at every stop, and deliver them around the cycle to their destinations. The headway clamp keeps Car 2 from overtaking Car 1 even when Car 1 stops for a heavy boarding burst.
Snapshots
LineKind round-trips through snapshots. Snapshots taken with a Loop scenario can be restored into another sim with the feature enabled. A snapshot containing LineKind::Loop cannot be restored by a sim built without the loop_lines feature – deserialization rejects the variant outright.
The wire format will keep the legacy flat min_position / max_position fields alongside kind for one release after this feature lands, to give snapshots produced by pre-feature sims a deterministic deserialization path. Once that release cycle passes, the flat fields will be removed.
Driving a Loop car manually
A host that wants to drive a Loop car directly (player-controlled gondola, AI-scripted train, etc.) puts the car in ServiceMode::Manual and sets its target velocity:
#![allow(unused)]
fn main() {
use elevator_core::components::ServiceMode;
use elevator_core::sim::Simulation;
use elevator_core::entity::ElevatorId;
fn drive(sim: &mut Simulation, car: ElevatorId) -> Result<(), elevator_core::error::SimError> {
sim.set_service_mode(car.entity(), ServiceMode::Manual)?;
sim.set_target_velocity(car, 2.5)?;
Ok(()) }
}
Two invariants distinguish Manual on a Loop from Manual on a Linear shaft:
- One-way:
set_target_velocityrejects negative targets withSimError::InvalidConfig { field: "target_velocity", .. }. Loops are closed cycles; “reverse” has no physical meaning, and silently clamping to zero would surprise authors. Hosts should surface the error as a UX warning. - Headway-clamped: the integrator pulls each tick’s landing back to
leader - min_headwayalong the forward direction. A player flooring the throttle into the car ahead produces a “soft collision” — the car physically stops at the headway boundary andvelocityis zeroed. Releasing throttle or waiting for the leader to move releases the clamp on the next tick.
For loop-aware game UI, the simulation exposes:
| Method | What it returns |
|---|---|
is_loop(line) | Whether the line uses LineKind::Loop |
loop_circumference(line) | Total path length, or None for Linear |
loop_next_stop(line, position) | Forward-most stop after position |
loop_leader(elevator) | Forward-nearest elevator on the same Loop, or None for a solo car |
loop_forward_gap(elevator) | Cyclic arc from elevator to its leader in [0, C) |
Hosts use loop_forward_gap together with the line’s min_headway to detect a car pressed against the clamp (“can’t advance — gap == min_headway”), then surface that state in HUDs or AI behaviour.
Out of scope for v1
The loop_lines v1 ships one-way loops only. The following are explicitly out of scope and rejected at construction or runtime:
- Bidirectional loops
- Pull-a-car-out-of-the-loop with follower retargeting (a Loop car can be put
OutOfServiceonly when it has no followers) - Block signalling for multi-segment lines
Future iterations may revisit these once the v1 surface has soaked.
Next steps
- Dispatch Strategies – the broader strategy interface that
LoopSweepandLoopScheduleplug into - Stops, Lines, and Groups – the topology primitives
LineKindextends - Movement and Physics – the trapezoidal integrator that the cyclic seam-split builds on
- Configuration – the RON schema
loop_demo.ronuses
Extensions
The core library is deliberately unopinionated – it provides riders, elevators, and stops, but caller code decides what a rider means. A rider could be a hotel guest with a priority class, an office tenant with a tenant id, a hospital patient with a transport type, or a freight crate with a manifest. Extensions let you layer caller-defined data on top of any entity without forking or wrapping the library.
Extension components
Extension components attach arbitrary typed data to any entity. They work like the built-in components (Rider, Elevator, etc.) but are defined by your code.
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 GuestPriority {
/// 0 = standard guest, 1 = elite, 2 = top-tier suite.
priority_class: u8,
/// Floor where the guest's room lives. A custom dispatch
/// strategy can consult this to favour the express bank that
/// serves the high-floor suites.
suite_floor: u32,
}
}
Register with the builder
Call .with_ext::<T>() on the builder to register the extension type. The type name is used automatically for snapshot serialization:
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct GuestPriority { priority_class: u8, suite_floor: u32 }
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::<GuestPriority>()
.build()?;
Ok(())
}
Attach to entities
Use world.insert_ext() to attach your component to an entity:
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct GuestPriority { priority_class: u8, suite_floor: u32 }
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
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::<GuestPriority>()
.build()?;
let rider_id = sim.spawn_rider(StopId(0), StopId(1), 75.0)?;
sim.world_mut().insert_ext(
rider_id.entity(),
GuestPriority { priority_class: 2, suite_floor: 47 },
ExtKey::from_type_name(),
);
Ok(())
}
Read it back
Use world.ext() for a cloned value, world.ext_ref() for a zero-copy borrow, or world.ext_mut() for a mutable reference:
#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct GuestPriority { priority_class: u8, suite_floor: u32 }
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &mut Simulation, rider_id: EntityId) {
// Read (cloned)
if let Some(guest) = sim.world().ext::<GuestPriority>(rider_id) {
println!("priority class: {}", guest.priority_class);
}
// Mutate -- promote the guest's class.
if let Some(guest) = sim.world_mut().ext_mut::<GuestPriority>(rider_id) {
guest.priority_class = guest.priority_class.saturating_add(1);
}
}
}
Query with extensions
Extensions integrate with the query builder for ECS-style iteration:
#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct GuestPriority { priority_class: u8, suite_floor: u32 }
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
use elevator_core::query::Ext;
fn run(world: &mut World) {
// Read-only iteration (cloned via Ext<T>)
for (id, guest) in world.query::<(EntityId, &Ext<GuestPriority>)>().iter() {
println!("{:?} is priority class {}", id, guest.priority_class);
}
// Mutable access -- promote everyone by one class (with saturation).
world.query_ext_mut::<GuestPriority>().for_each_mut(|_id, guest| {
guest.priority_class = guest.priority_class.saturating_add(1);
});
}
}
The mutable query collects entity IDs first, then iterates with mutable borrows, so it is safe to use without aliasing issues.
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::*;
use elevator_core::__doctest_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 caller-side state that hooks need to read or write – a tick-of-day clock mirror, a peak-traffic multiplier, a spawn-rate controller, and so on.
Extension vs. resource: which do I want?
| Need | Use |
|---|---|
| Per-entity data that varies by rider/elevator (priority class, transport type, cargo manifest) | Extension (with_ext + insert_ext) |
| One-of value for the whole sim (time-of-day, traffic profile, tick clock mirror) | Resource (insert_resource) |
| Data that must survive snapshot save/load | Extension (registered by name; resources are not snapshotted) |
| Quick scratchpad you can wipe between ticks | Resource |
| Query “all entities that have X” | Extension (query or iterate + filter on get_ext) |
Extensions are auto-cleaned on despawn_rider; resources persist until you remove them.
Snapshot integration
Extension components are serialized by their registered type name into the WorldSnapshot. To restore them correctly after loading a snapshot:
- Register the extension types on the restored simulation’s world.
- Call
sim.load_extensions()to deserialize and attach the pending data.
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
use elevator_core::snapshot::WorldSnapshot;
use serde::{Serialize, Deserialize};
#[derive(Clone, Serialize, Deserialize)] struct GuestPriority { priority_class: u8, suite_floor: u32 }
use elevator_core::snapshot::RestoreOptions;
fn run(snapshot: WorldSnapshot) {
let mut sim = snapshot.restore(RestoreOptions::default()).unwrap();
sim.world_mut().register_ext::<GuestPriority>(ExtKey::from_type_name());
sim.load_extensions();
}
}
Unregistered types remain in a PendingExtensions resource until you register them. If you have many extension types, use the register_extensions! macro to register them all in one call.
The key rule: register with with_ext before building, or with register_ext before restoring. If an extension type is missing at restore time, its data is silently held in pending storage rather than lost.
Auto-cleanup on despawn
Extension components are automatically removed when an entity is despawned. You don’t need to manually clean up extension data – despawn_rider, remove_elevator, and other removal APIs handle it.
This means you can freely attach extensions to riders that will be delivered and despawned without worrying about leaked data in the extension storage maps.
Next steps
- Lifecycle Hooks – inject custom logic that reads and writes extension data each tick.
- Writing a Custom Dispatch – strategies can consult extension data via
world.ext::<T>(id)in theirrankfunction. - Snapshots and Determinism – full snapshot/restore cycle including extension registration.
Lifecycle Hooks
Hooks let you inject custom logic before or after any of the simulation’s tick phases. They receive &mut World, so they can read and modify any entity, extension, or resource – the primary integration point for game-specific behavior that should run every tick.
Registering hooks on the builder
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::hooks::Phase;
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| {
// Runs after every Loading phase.
// Check for newly arrived riders, update scores, etc.
})
.before(Phase::Dispatch, |world| {
// Runs before every Dispatch phase.
// Adjust demand, spawn dynamic riders, etc.
})
.build()?;
Ok(())
}
Registering hooks after build
You can add hooks to a running simulation. This is useful when hook logic depends on runtime state that isn’t available at build time:
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
use elevator_core::hooks::Phase;
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::__doctest_prelude::*;
use elevator_core::hooks::Phase;
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(())
}
Group-specific hooks run alongside global hooks for the same phase; all before hooks fire before the phase body, all after hooks fire after.
What hooks can and can’t do
Hooks receive &mut World – not &mut Simulation. This means:
You can:
- Read and mutate any component (
world.rider(id),world.elevator_mut(id)) - Insert, read, and remove extensions (
world.insert_ext(),world.get_ext_mut()) - Add and remove resources (
world.insert_resource(),world.resource_mut()) - Read tick state via a resource mirror (see the worked example below)
You cannot:
- Call
sim.step(),sim.spawn_rider(),sim.snapshot(), or otherSimulation-level methods – the simulation is borrowed while the tick is in flight. - Emit events directly. Use an extension or resource to record side effects and translate them to events in your game code after
sim.step()returns.
Available phases
| Phase | When hooks run |
|---|---|
Phase::AdvanceTransient | Before/after transitional states advance (Boarding to Riding, Exiting to Arrived, patience ticking) |
Phase::Dispatch | Before/after elevator assignment |
Phase::Reposition | Before/after idle-elevator repositioning (no-op if no reposition strategy configured) |
Phase::AdvanceQueue | Before/after destination queue reconciliation with elevator phase/target |
Phase::Movement | Before/after position updates |
Phase::Doors | Before/after door state machine ticks |
Phase::Loading | Before/after boarding and exiting |
Phase::Metrics | Before/after metric aggregation |
Hook execution order within a tick
Hooks run in strict phase order. Within each phase, all before hooks fire first, then the phase body executes, then all after hooks fire:
before(AdvanceTransient) -> [AdvanceTransient] -> after(AdvanceTransient)
before(Dispatch) -> [Dispatch] -> after(Dispatch)
before(Reposition) -> [Reposition] -> after(Reposition)
before(AdvanceQueue) -> [AdvanceQueue] -> after(AdvanceQueue)
before(Movement) -> [Movement] -> after(Movement)
before(Doors) -> [Doors] -> after(Doors)
before(Loading) -> [Loading] -> after(Loading)
before(Metrics) -> [Metrics] -> after(Metrics)
Multiple hooks registered for the same slot run in registration order.
Spawning during hooks
Hooks operate on &mut World, so you can use world.spawn() plus direct component inserts to create entities. However, the recommended pattern is to queue spawn requests into a resource and drain them after sim.step() returns.
The before(Phase::Dispatch) slot is a particularly convenient place to inject riders, because the dispatch phase runs immediately after and will see the newly added riders in the manifest.
Combining extensions and hooks: worked example
A facilities team monitoring an office tower wants to know whenever a tenant has been waiting for an elevator for more than 90 seconds during peak traffic – it is a service-level indicator and feeds an end-of-day report. This walkthrough flags each over-budget wait exactly once via a SlaBreach extension component plus an after(Phase::Metrics) hook.
The hook closure cannot call sim.current_tick() directly (the simulation is borrowed during the tick), so the tick is mirrored into a World resource that the hook reads.
use elevator_core::hooks::Phase;
use elevator_core::prelude::*;
use elevator_core::world::ExtKey;
use serde::{Serialize, Deserialize};
/// Per-rider flag that latches the first time a tenant breaches the
/// 90-second SLA so we report each wait at most once.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SlaBreach {
reported: bool,
}
// 60 ticks/sec * 90 sec = 5_400 ticks.
const SLA_BUDGET_TICKS: u64 = 5_400;
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::<SlaBreach>()
.after(Phase::Metrics, |world| {
// Scan every rider; flag the first time they cross the SLA budget.
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 > SLA_BUDGET_TICKS {
if let Some(breach) = world.ext_mut::<SlaBreach>(rid) {
if !breach.reported {
breach.reported = true;
println!(
"SLA breach: rider {:?} waited {} ticks (>{} budget)",
rid, wait, SLA_BUDGET_TICKS,
);
}
}
}
}
})
.build()?;
// Seed the resource the hook reads.
sim.world_mut().insert_resource(CurrentTick(0));
// Spawn a tenant and attach the SLA tracker.
let r1 = sim.spawn_rider(StopId(0), StopId(2), 75.0)?;
sim.world_mut().insert_ext(r1.entity(), SlaBreach { reported: false }, ExtKey::from_type_name());
for _ in 0..6_000 {
// Mirror the current tick into the resource before stepping.
let now = sim.current_tick();
if let Some(t) = sim.world_mut().resource_mut::<CurrentTick>() {
t.0 = now;
}
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 layer caller-defined behaviour on top of the simulation.
Next steps
- Extensions – the extension system that hooks read and write.
- The Simulation Loop – the 8-phase tick loop that hooks wrap around.
- Writing a Custom Dispatch – for logic that should influence car assignment rather than react to it.
Manual and Inspection Modes
Every elevator has a ServiceMode that controls how the simulation treats it. The default is Normal – dispatch assigns stops, doors auto-cycle, and the elevator moves autonomously. The other modes hand varying degrees of control to your game code, enabling player-controlled elevators, maintenance scenarios, and direct API-driven movement.
ServiceMode overview
| Mode | Dispatch | Movement | Doors | Use case |
|---|---|---|---|---|
Normal | Automatic | Automatic (trapezoidal profile) | Auto-cycle | Standard operation |
Independent | Excluded | Direct API calls only | Auto-cycle | Responds to car calls, not hall calls |
Inspection | Automatic | Reduced speed | Hold open | Maintenance / inspection |
Manual | Excluded | Velocity commands | Manual door API | Player-controlled elevator |
Setting the mode
Use sim.set_service_mode() to change an elevator’s mode at any time. The method emits a ServiceModeChanged event if the mode actually changes:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &mut Simulation, elev: EntityId) -> Result<(), SimError> {
sim.set_service_mode(elev, ServiceMode::Manual)?;
Ok(())
}
}
When transitioning out of Manual mode, the library automatically clears the pending velocity command and zeros the velocity component. This prevents a car that was moving at transition time from being stranded – the normal movement system only runs for MovingToStop / Repositioning phases.
Manual mode: player-controlled elevators
Manual mode is designed for games where the player directly drives an elevator. The elevator is excluded from dispatch and repositioning; movement is controlled entirely through velocity commands.
Velocity control
Set the target velocity with sim.set_target_velocity(). The elevator accelerates toward this velocity using its configured kinematic caps (max speed, acceleration, deceleration):
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &mut Simulation, elev: ElevatorId) -> Result<(), SimError> {
// Command the elevator upward.
sim.set_target_velocity(elev, 2.0)?;
// Command it downward.
sim.set_target_velocity(elev, -1.5)?;
// Slow to a stop (velocity ramps down via deceleration).
sim.set_target_velocity(elev, 0.0)?;
Ok(())
}
}
The elevator respects its physics parameters – it won’t instantly jump to the target velocity but will accelerate and decelerate smoothly. The car can stop at any position; it is not required to align with a configured stop.
Emergency stop
Trigger an immediate deceleration to zero:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &mut Simulation, elev: ElevatorId) -> Result<(), SimError> {
sim.emergency_stop(elev)?;
Ok(())
}
}
Both set_target_velocity and emergency_stop require the elevator to be in ServiceMode::Manual and not disabled. They return SimError::WrongServiceMode if called on a non-manual elevator.
Complete example
This example puts an elevator into manual mode, commands it upward, then triggers an emergency stop halfway through:
use elevator_core::prelude::*;
use elevator_core::components::ServiceMode;
fn main() {
let mut sim = SimulationBuilder::demo().build().unwrap();
let elev = ElevatorId::from(sim.world().iter_elevators().next().unwrap().0);
sim.set_service_mode(elev.entity(), ServiceMode::Manual).unwrap();
// Command full ascent.
sim.set_target_velocity(elev, 2.0).unwrap();
for t in 0..180 {
// Halfway through, slam the emergency brake.
if t == 90 {
sim.emergency_stop(elev).unwrap();
}
sim.step();
let pos = sim.world().position(elev.entity()).unwrap().value();
let vel = sim.velocity(elev.entity()).unwrap();
if t > 90 && vel.abs() < 1e-6 {
println!("Car stopped at {pos:.2}m after {t} ticks.");
break;
}
}
}
Run it with cargo run -p elevator-core --example manual_driver.
Inspection mode
Inspection mode reduces the elevator’s speed by its inspection_speed_factor (default: 0.25, configurable per elevator). Doors hold open indefinitely rather than auto-cycling. The elevator still participates in dispatch – it just moves slowly.
This mode is useful for maintenance walk-throughs and operator-driven inspection runs – the cab moves slowly enough for a technician to listen for anomalies along the shaft, and the doors stay open at each stop until the operator dismisses them.
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &mut Simulation, elev: EntityId) -> Result<(), SimError> {
sim.set_service_mode(elev, ServiceMode::Inspection)?;
// The elevator now moves at 25% of its normal max speed.
Ok(())
}
}
The speed factor is configured per elevator via ElevatorConfig::inspection_speed_factor and can be read at runtime with elevator.inspection_speed_factor().
Independent mode
Independent mode removes the elevator from automatic dispatch and repositioning. You control movement via direct API calls (e.g., push_destination). The elevator responds only to car calls – explicit stop requests – not to hall calls from waiting riders.
This is useful for freight elevators, service lifts, or any elevator that should only move when explicitly commanded:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &mut Simulation, elev: ElevatorId) -> Result<(), SimError> {
sim.set_service_mode(elev.entity(), ServiceMode::Independent)?;
// Now manually send it somewhere.
sim.push_destination(elev, StopId(2))?;
// Changed your mind mid-trip? Brake the car to the nearest stop.
sim.abort_movement(elev)?;
Ok(())
}
}
Disabling an elevator
Separate from ServiceMode, elevators can be taken out of service entirely using sim.disable(). A disabled elevator:
- Is excluded from all simulation phases (dispatch, movement, doors, loading)
- Ejects all current riders
- Emits an
EntityDisabledevent
Re-enable with sim.enable(), which emits EntityEnabled. Most Simulation methods return SimError::ElevatorDisabled when called on a disabled elevator.
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &mut Simulation, elev: EntityId) -> Result<(), SimError> {
// Take out of service.
sim.disable(elev)?;
// Later, bring back online.
sim.enable(elev)?;
Ok(())
}
}
Events on mode changes
The Event::ServiceModeChanged event fires whenever set_service_mode changes an elevator’s mode. It carries the from and to modes along with the elevator entity and tick:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn handle(event: Event) {
if let Event::ServiceModeChanged { elevator, from, to, tick } = event {
let _ = (elevator, from, to, tick); // use the fields in your game
}
}
}
If you set the mode to its current value, no event is emitted and the call returns Ok(()).
Next steps
- Movement and Physics – how the trapezoidal velocity profile works under the hood.
- Events and Metrics – consuming
ServiceModeChangedand other events. - Lifecycle Hooks – inject logic that reacts to mode changes within the tick loop.
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, TrafficSource};
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(req.origin, req.destination, req.weight);
}
sim.step();
}
Ok(())
}
Patterns
TrafficPattern selects origin/destination distributions. Five presets cover common building scenarios:
| Pattern | Distribution |
|---|---|
Uniform | Equal probability for all origin/destination pairs |
UpPeak | 80% from lobby, 20% inter-floor (morning rush) |
DownPeak | 80% to lobby, 20% inter-floor (evening rush) |
Lunchtime | 40% upper to mid, 40% mid to upper, 20% random |
Mixed | 30% up-peak, 30% down-peak, 40% inter-floor |
The first stop in the slice is treated as the “lobby” – make sure stops are sorted by position.
#![allow(unused)]
fn main() {
use elevator_core::traffic::TrafficPattern;
fn run(stops: &[elevator_core::stop::StopId]) {
let mut rng = rand::rng();
if let Some((origin, destination)) = TrafficPattern::UpPeak.sample_stop_ids(stops, &mut rng) {
println!("{origin} -> {destination}");
}
}
}
Schedules
A TrafficSchedule maps tick ranges to patterns, enabling realistic daily cycles:
#![allow(unused)]
fn main() {
use elevator_core::traffic::{TrafficPattern, TrafficSchedule};
let schedule = TrafficSchedule::new(vec![
(0..3600, TrafficPattern::UpPeak), // First hour: morning rush
(3600..7200, TrafficPattern::Uniform), // Second hour: normal
(7200..10800, TrafficPattern::Lunchtime), // Third hour: lunch
(10800..14400, TrafficPattern::DownPeak), // Fourth hour: evening rush
]);
}
When the current tick falls outside all segments, the schedule uses a fallback pattern (default: Uniform):
#![allow(unused)]
fn main() {
use elevator_core::traffic::{TrafficPattern, TrafficSchedule};
let schedule = TrafficSchedule::new(vec![(0..1000, TrafficPattern::UpPeak)])
.with_fallback(TrafficPattern::Mixed);
}
Built-in schedule presets
TrafficSchedule::office_day(ticks_per_hour)– typical 9-hour office day with morning rush, lunch, and evening rushTrafficSchedule::constant(pattern)– a single pattern for all ticks
Poisson arrivals
PoissonSource is the default traffic generator. It uses exponential inter-arrival times – a standard Poisson process – driven by a mean interval parameter:
#![allow(unused)]
fn main() {
use elevator_core::traffic::{PoissonSource, TrafficSchedule, TrafficPattern};
use elevator_core::stop::StopId;
let stops = vec![StopId(0), StopId(1), StopId(2)];
let source = PoissonSource::new(
stops,
TrafficSchedule::constant(TrafficPattern::Uniform),
60, // mean arrival every 60 ticks
(50.0, 100.0), // weight range (min, max) in kg
);
}
Each call to source.generate(tick) returns a Vec<SpawnRequest> – zero, one, or multiple requests depending on how many arrivals are due since the last call.
From config
If your SimConfig already has passenger_spawning populated, use from_config:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_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 an OS-seeded 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::{RngExt, 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:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
use elevator_core::traffic::SpawnRequest;
// Constructing a SpawnRequest to feed into the simulation:
let req = SpawnRequest {
origin: StopId(0),
destination: StopId(1),
weight: 75.0,
};
let _ = req;
}
For riders that need patience, preferences, or access control, spawn through the simulation’s build_rider fluent API instead of using spawn_rider directly:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
use elevator_core::traffic::SpawnRequest;
fn run(sim: &mut Simulation, req: SpawnRequest) -> Result<(), SimError> {
sim.build_rider(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
- Snapshots and Determinism – seeded traffic is essential for reproducible simulations and regression testing.
- Events and Metrics – how generated traffic produces events and summary statistics.
- Testing Your Simulation – scripted spawn schedules for automated scenario testing.
Snapshots and Determinism
elevator-core is designed for reproducible simulations. This chapter covers the determinism guarantees, the snapshot save/load API, and the patterns for replay, regression testing, and research comparisons.
Determinism guarantee
The simulation is deterministic given:
- The same initial
SimConfig(same stops, elevators, groups, lines, dispatch strategy). - The same sequence of API calls (
spawn_rider,despawn_rider,tag_entity, hook mutations, etc.). - A deterministic dispatch strategy. Every built-in (see Dispatch Strategies) is deterministic, and each one round-trips its identity, tunable weights, and internal per-car state through
WorldSnapshotsosnapshot + restoreproduces an indistinguishable simulation.
Under those conditions two runs produce byte-identical snapshots and event streams. The cross-strategy invariant harness in crates/elevator-core/src/tests/invariants_tests.rs pins this tick-for-tick across all built-ins.
Sources of non-determinism to watch for:
PoissonSourceand similar traffic generators use a thread-local RNG. See Traffic Generation – Determinism and seeding.- Custom dispatch strategies or hooks that read wall-clock time, thread IDs, or unseeded RNGs.
- HashMap iteration order in your own hook code (the sim itself uses stable iteration via
BTreeMap).
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.
flowchart LR
simA["Simulation A<br/>(at tick N)"] -->|sim.snapshot| ws1["WorldSnapshot"]
ws1 -->|serde<br/>(RON / JSON /<br/>bincode / …)| bytes["bytes"]
bytes -->|deserialize| ws2["WorldSnapshot"]
ws2 -->|Simulation::from_snapshot| simB["Simulation B<br/>(at tick N)"]
simA -.snapshot_checksum.-> chk["checksum"]
simB -.snapshot_checksum.-> chk
chk -.compared in.-> harness["elevator-contract<br/>vs. golden.txt"]
classDef artifact fill:#1c1c1c,stroke:#666,color:#eee
class ws1,ws2,bytes,chk artifact
Two simulations restored from the same bytes produce byte-identical snapshots forever after, given the same input sequence. The elevator-contract harness pins this across hosts: the core run and the wasm-bindgen run share a single golden.txt checksum.
Saving
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
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::__doctest_prelude::*;
use elevator_core::snapshot::WorldSnapshot;
use elevator_core::snapshot::RestoreOptions;
fn main() -> Result<(), SimError> {
let bytes = std::fs::read_to_string("save.ron").unwrap();
let snapshot: WorldSnapshot = ron::from_str(&bytes).unwrap();
// `RestoreOptions::default()` means "only built-in dispatch strategies";
// use `RestoreOptions::with_factory(...)` to resurrect custom strategies
// registered by name.
let sim = snapshot.restore(RestoreOptions::default())?;
Ok(())
}
Entity ID remapping
On restore, fresh EntityId values are generated (SlotMap keys are not stable across sessions). The snapshot stores entity data by index; restore() builds an old_id -> new_id mapping and remaps all cross-references (elevator riders, rider phases, route legs, group caches). This is transparent to callers.
Custom dispatch across restore
Built-in strategies (Scan, Look, NearestCar, Etd, Rsr, Destination) are auto-restored by name – BuiltinStrategy::instantiate() rebuilds each with default weights, and any tunable configuration applied via with_* builder methods is replayed from snapshot_config / restore_config immediately after. Custom strategies need a factory:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
use elevator_core::snapshot::WorldSnapshot;
struct HighestFirstDispatch;
impl DispatchStrategy for HighestFirstDispatch {
fn rank(&self, _ctx: &RankContext<'_>) -> Option<f64> { Some(0.0) }
}
use elevator_core::snapshot::RestoreOptions;
fn run(snapshot: WorldSnapshot) {
let sim = snapshot.restore(RestoreOptions::with_factory(&|name: &str| match name {
"HighestFirst" => Some(Box::new(HighestFirstDispatch) as Box<dyn DispatchStrategy>),
_ => None,
}));
}
}
Custom strategies register their snapshot identity by overriding DispatchStrategy::builtin_id to return BuiltinStrategy::Custom("name"); that name is what the snapshot stores and what the factory closure receives on restore – make sure the two match. Overriding snapshot_config / restore_config gives the same tuning-survival guarantee the built-ins get. See Writing a Custom Dispatch – Step 4 for the full pattern.
Extensions across restore
Extensions are serialized by their registered name. Dispatch-internal extensions the sim itself owns (currently AssignedCar for DestinationDispatch) are auto-registered and auto-deserialized in Simulation::from_parts, so a DCS snapshot round-trip preserves sticky rider assignments without caller involvement. Game-owned extensions still need manual re-registration – re-register on the restored simulation’s world and call load_extensions:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
use elevator_core::snapshot::WorldSnapshot;
use serde::{Serialize, Deserialize};
#[derive(Clone, Serialize, Deserialize)] struct VipTag;
use elevator_core::snapshot::RestoreOptions;
fn run(snapshot: WorldSnapshot) {
let mut sim = snapshot.restore(RestoreOptions::default()).unwrap();
sim.world_mut().register_ext::<VipTag>(ExtKey::from_type_name());
sim.load_extensions();
}
}
Use the register_extensions! macro to register many types in one line. See Extensions – Snapshot integration for details.
Patterns
Replay
- Serialize the initial config.
- Log every external mutation (
spawn_rider,despawn_rider, tag changes) with its tick. - To replay: rebuild the sim from config, then step while replaying logged mutations at the right ticks.
Snapshots are a stronger alternative – you can start replay from any tick by restoring a snapshot taken at that tick.
Regression testing
Run a seeded scenario for N ticks, snapshot, and diff against a golden snapshot:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(sim: &mut Simulation, expected: &str) {
let snap = sim.snapshot();
let actual = ron::to_string(&snap).unwrap();
// Compare against a golden file checked into the repo:
// let expected = include_str!("../golden/scenario_a.ron");
assert_eq!(actual, expected);
}
}
This catches unintended behavior changes anywhere in the tick pipeline. See Testing Your Simulation for more patterns.
Research comparisons
To compare dispatch strategies fairly, use identical seeded traffic across runs:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
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.
}
Build both simulations from the same config and feed them the same seeded TrafficSource. After running for the same number of ticks, compare sim.metrics() to see which strategy performs better on wait time, throughput, or any other metric.
Next steps
- Testing Your Simulation – snapshot round-trips, deterministic replay tests, and scenario scripting.
- Performance – scaling guidance and benchmark interpretation.
- Traffic Generation – seeded traffic sources for reproducible experiments.
Snapshot Versioning
This page formalises the contract for two distinct version markers that
ride along with every snapshot: a schema_version: u32 and a crate
semver string. They mean different things, are checked through different
paths, and bump on different signals. This page is the canonical
reference; the constants in crates/elevator-core/src/snapshot.rs
defer to it.
What gets versioned
Two markers travel with a snapshot:
schema_version: u32lives onWorldSnapshotitself. It is serialized into RON, JSON, and any other custom serde format. The current value isSNAPSHOT_SCHEMA_VERSIONinsnapshot.rs.- Crate semver string (
env!("CARGO_PKG_VERSION")) is wrapped around the payload bySnapshotEnvelopeand serialized only when using the bytes path (Simulation::snapshot_bytes/Simulation::restore_bytes— postcard-encoded).
The two paths therefore have asymmetric guarantees:
| Path | schema_version checked | Crate version checked |
|---|---|---|
WorldSnapshot::restore (RON / JSON / arbitrary serde) | yes | no — the format has no envelope |
Simulation::restore_bytes (postcard envelope) | yes (transitively, via inner restore) | yes |
Both reject mismatches with SimError::SnapshotVersion.
When to bump schema_version
Bump the u32 when the snapshot layout changes in a way that an older
binary should not silently load. The classic trap, fixed in #295, is
serde’s #[serde(default)]: an old snapshot loaded by a new binary
quietly fills missing fields with their defaults, masking the fact that
the data was written by a different schema. The version field exists to
convert that silent acceptance into an explicit SnapshotVersion error.
Bump triggers:
- A field changes meaning (same name, different semantics).
- A field is removed and the new code can’t reconstruct it from what’s left.
- The shape of an existing variant changes (renamed enum variants, re-ordered tuple fields, anything that breaks structural compat).
- A new field is added whose absence would silently miscompute on restore — for example, a counter where “missing = 0” is wrong.
Do not bump for:
- Purely additive fields whose serde default is genuinely correct on
legacy snapshots (the field is a new aggregate that starts empty, a
cooldown map that’s allowed to be empty, etc.). Add the field with
#[serde(default)]and document why “missing = default” is safe in the field’s doc comment. Pre-versioning examples includearrival_log_retention,destination_log, andreposition_cooldowns— each carries a doc comment explaining the legacy behaviour.
When you bump, update the constant in one place
(SNAPSHOT_SCHEMA_VERSION) and add a regression test that mounts a
snapshot from the previous version and asserts SimError::SnapshotVersion.
The existing snapshot tests in
crates/elevator-core/src/tests/snapshot_tests.rs cover this pattern.
When the crate version is the right gate
The crate semver string in the bytes envelope is a stricter check: it rejects any version mismatch, even patch bumps that didn’t touch the schema. That’s intentional for the bytes path — bincode/postcard encodings are sensitive to layout changes the schema version doesn’t catch (e.g. a pure ordering change in a struct’s field declaration re-encodes differently on the wire). Tying it to the crate version means “this exact build produced this exact bytes layout, no compat layer”.
If you need cross-version bytes compatibility, that is an explicit feature request — and it will require either a stable serializer (we don’t currently provide one) or migrating to a self-describing format like RON for the cross-build hop, then re-encoding to bytes locally.
Migration policy
The current policy is strict-reject only: snapshots from a different
schema_version (RON/JSON) or a different crate version (bytes) error
out. There is no migration layer.
A future migration path, if added, would live on WorldSnapshot::restore
and dispatch on self.version:
- The schema version stays a single
u32constant. - Each bump from
NtoN+1lands with an in-treemigrate_v{N}_to_v{N+1}function that runs before the strict version check and rewrites the deserialized snapshot in place. - Test fixtures from version
Nare kept undercrates/elevator-core/tests/fixtures/snapshots/and round-tripped throughrestoreto prove the migration chain.
Until migration support exists, the contract is: callers that need to load older snapshots must keep the old binary around and re-snapshot under the new one. For RON/JSON consumers this is sometimes a manual fixup — for bytes consumers it is unavoidable.
Quick reference
- Bump
schema_versionfor any layout or semantic change that isn’t trivially additive-with-correct-default. - The crate version covers the bytes-envelope path; you do not bump it
manually for snapshots —
cargodoes it on every release. - Both checks return
SimError::SnapshotVersionwithsavedandcurrentstrings the host can surface to the user. - New additive field with safe default → just add it, document the default in the field’s doc comment, no version bump.
Next steps
- Read Snapshots and Determinism for usage patterns and the determinism contract on the encoded bytes.
- The constants and types referenced here live in
crates/elevator-core/src/snapshot.rs; the matching error variant isSimError::SnapshotVersionincrates/elevator-core/src/error.rs.
Config Versioning
SimConfig (the RON format under assets/config/) carries an explicit
schema_version: u32 field that the core validates at construction
time. This page is the canonical reference for what the version means,
when to bump it, and how to migrate older configs.
Why explicit versioning
Without an explicit version, a legacy assets/config/*.ron loaded by a
newer build would silently fall through serde’s default paths for
any newly-added fields and reject any removed-or-renamed fields with a
generic deserialization error. Both modes mask intent: the operator
sees a config that “looks loaded” but is actually running a different
shape than they wrote.
The explicit schema_version makes the contract observable:
- A legacy config (no
schema_versionfield) deserializes toschema_version: 0via#[serde(default)]. Simulation::newrejectsschema_version: 0with anInvalidConfigerror pointing at this page, so the operator audits the field defaults rather than running a silent migration.- A config with
schema_version > CURRENT_CONFIG_SCHEMA_VERSIONis rejected as forward-incompatible; the operator must upgradeelevator-coreor downgrade the config.
Asymmetry with snapshot versioning
Snapshots (see Snapshot Versioning) carry
two version markers — a u32 schema number and a crate-version
string in the bytes envelope. Configs only need the u32, because:
- Configs are human-edited RON, so the
crate-versionenvelope (which pins encoder details for postcard) has no equivalent — RON is text-stable across crate versions. - The set of compatible mismatches is much smaller: there is no “savefile from a different patch release” case. Either the schema matches or it does not.
Bump triggers
Bump CURRENT_CONFIG_SCHEMA_VERSION (in crates/elevator-core/src/config.rs)
when any of these change in a way that legacy assets/config/*.ron
files would silently mis-deserialize:
- A field is removed or renamed.
- A field’s type changes (
u32→Option<u32>,Vec<T>→BTreeMap<K, T>). - A field’s default value changes in a way that materially alters behaviour (e.g. flipping a feature flag’s default, changing a capacity threshold).
- The set of valid enum variants expands and the additions are not
marked
#[non_exhaustive]-safe (the legacy config would silently pick the deserialization fallback on the new variants).
Do not bump for:
- Adding a new optional field with
#[serde(default)]whose default matches existing behaviour. Legacy configs continue to deserialize correctly; bumping would force operators to audit an unchanged shape. - Pure refactors that reorder fields, rename internal types, or reorganize modules without changing the RON shape.
Migration playbook
When bumping the version, in the same PR:
- Update
CURRENT_CONFIG_SCHEMA_VERSIONand the doc comment on the constant naming the change. - Update every
assets/config/*.ronto declare the newschema_versionvalue. - Add a migration entry to this page (a “v1 → v2” subsection) listing every changed field and the manual edit operators must apply.
- If the change is mechanical, ship a one-shot upgrade tool
(
elevator-config-upgrade --from 1 --to 2 < old.ron > new.ron) alongside the doc.
The migration entries are kept in this doc so an operator carrying a
pinned config across multiple core upgrades has a single canonical
source for the diff. Migration code does not live in core itself —
the validator is intentionally strict about version mismatches; the
upgrade lives outside the read path.
Migrations
Versions issued so far. Each section below lists the field changes the operator must apply when moving a config from the previous version to the current one.
v0 → v1 (initial versioning)
There is no shape change; v1 is the first explicit version marker. To adopt:
- Open the RON file and add
schema_version: 1,as the first field inside the top-levelSimConfig(...)block. - Run
cargo run -- path/to/config.ron(orSimulation::newfrom Rust) to confirm validation passes.
After v1, every v(N) → v(N+1) transition will have a real shape diff and an entry here.
Related
crates/elevator-core/src/config.rs— theSimConfigstruct definition andCURRENT_CONFIG_SCHEMA_VERSION.- Snapshot Versioning — the parallel policy
for
WorldSnapshotbytes. Simulation::new— the validator that surfaces version mismatches asSimError::InvalidConfig { field: "schema_version", reason: ... }.
Next steps
- For new configs, copy
assets/config/default.ronand adjust — it always pins to the current schema version. - For older configs, use the v0 → v1 entry above as a template; future bumps will add new entries describing each shape change.
Testing Your Simulation
elevator-core’s deterministic tick loop makes it straightforward to write reliable, reproducible tests. This chapter covers the testing patterns used in the library itself and shows how to apply them to your own game code.
Deterministic replay
The simulation is deterministic: two identical scenarios with the same API call sequence produce byte-identical snapshots and event streams. This means you can test behavior by running a scenario twice and asserting the outputs match.
The key ingredients for a replay test:
- Build the simulation from a fixed config (no randomness in setup).
- Spawn riders at hard-coded ticks (avoid
PoissonSource– it uses a thread-local RNG). - Step for a fixed number of ticks and collect events.
- Run the same scenario again and compare.
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::dispatch::etd::EtdDispatch;
/// A rider spawn scheduled at a specific tick.
struct ScheduledSpawn {
tick: u64,
origin: StopId,
destination: StopId,
weight: f64,
}
fn car_config(id: u32, name: &str) -> ElevatorConfig {
ElevatorConfig {
id: elevator_core::config::ElevatorConfigId(id),
name: name.into(),
starting_stop: StopId(0),
..Default::default()
}
}
fn run_scenario(spawns: &[ScheduledSpawn], total_ticks: u64) -> (Vec<Event>, Metrics) {
let mut sim = SimulationBuilder::new()
.stop(StopId(0), "Ground", 0.0)
.stop(StopId(1), "Mid", 6.0)
.stop(StopId(2), "Top", 18.0)
.elevators(vec![car_config(0, "East"), car_config(1, "West")])
.dispatch(EtdDispatch::new())
.build()
.unwrap();
let mut events = Vec::new();
for tick in 0..total_ticks {
for spawn in spawns.iter().filter(|s| s.tick == tick) {
sim.spawn_rider(spawn.origin, spawn.destination, spawn.weight).unwrap();
}
sim.step();
events.extend(sim.drain_events());
}
(events, sim.metrics().clone())
}
#[test]
fn replay_is_deterministic() {
let spawns: Vec<ScheduledSpawn> = vec![/* fixed schedule */];
let (events_a, metrics_a) = run_scenario(&spawns, 5_000);
let (events_b, metrics_b) = run_scenario(&spawns, 5_000);
assert_eq!(events_a.len(), events_b.len());
for (a, b) in events_a.iter().zip(events_b.iter()) {
assert_eq!(a, b);
}
assert_eq!(metrics_a.total_delivered(), metrics_b.total_delivered());
}
}
A regression that introduces HashMap iteration into a code path, or any other nondeterministic ordering, will cause this test to fail.
Snapshot roundtrip testing
Save a snapshot, serialize it, deserialize it, restore, and verify the state matches:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
use elevator_core::snapshot::WorldSnapshot;
#[test]
fn snapshot_roundtrip_preserves_state() {
let mut sim = SimulationBuilder::demo().build().unwrap();
for _ in 0..3 {
sim.spawn_rider(StopId(0), StopId(1), 70.0).unwrap();
}
for _ in 0..100 {
sim.step();
}
let original_tick = sim.current_tick();
let original_delivered = sim.metrics().total_delivered();
// Snapshot, serialize, deserialize, restore.
let snap = sim.snapshot();
let ron_str = ron::to_string(&snap).unwrap();
let snap2: WorldSnapshot = ron::from_str(&ron_str).unwrap();
let restored = snap2.restore(elevator_core::snapshot::RestoreOptions::default()).unwrap();
assert_eq!(restored.current_tick(), original_tick);
assert_eq!(restored.metrics().total_delivered(), original_delivered);
let orig_riders = sim.world().iter_riders().count();
let rest_riders = restored.world().iter_riders().count();
assert_eq!(orig_riders, rest_riders);
}
}
This catches serialization bugs, entity ID remapping errors, and missing component roundtrips. If you use custom extensions, register them before restoring and call sim.load_extensions().
For elevators in non-trivial phases (e.g., Repositioning), verify that the phase variant and its inner entity reference survived the remap:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(restored: &Simulation, elev_id: EntityId) {
let restored_phase = restored.world().elevator(elev_id).unwrap().phase();
match restored_phase {
ElevatorPhase::Repositioning(target) => {
assert!(restored.world().stop(target).is_some());
}
other => panic!("expected Repositioning, got {other:?}"),
}
}
}
Scenario scripting
The scenario module provides a structured way to define timed rider spawns with pass/fail conditions. A Scenario bundles a SimConfig, a list of TimedSpawn events, evaluation conditions, and a tick limit:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::dispatch::scan::ScanDispatch;
use elevator_core::scenario::{Scenario, TimedSpawn, Condition, ScenarioRunner};
fn run(my_sim_config: SimConfig) {
let scenario = Scenario {
name: "Morning rush".into(),
config: my_sim_config,
spawns: vec![
TimedSpawn { tick: 0, origin: StopId(0), destination: StopId(2), weight: 72.0 },
TimedSpawn { tick: 15, origin: StopId(0), destination: StopId(1), weight: 85.0 },
TimedSpawn { tick: 60, origin: StopId(2), destination: StopId(0), weight: 68.0 },
],
conditions: vec![
Condition::AvgWaitBelow(100.0),
Condition::MaxWaitBelow(300),
Condition::AbandonmentRateBelow(0.05),
Condition::AllDeliveredByTick(5000),
],
max_ticks: 10_000,
};
let mut runner = ScenarioRunner::new(scenario, ScanDispatch::new()).unwrap();
let result = runner.run_to_completion();
assert!(result.passed, "scenario failed: {:?}", result.conditions);
}
}
Available conditions:
| Condition | Passes when |
|---|---|
AvgWaitBelow(f64) | Average wait time is below the threshold (ticks) |
MaxWaitBelow(u64) | Maximum wait time is below the threshold (ticks) |
ThroughputAbove(u64) | Throughput exceeds the threshold (riders per window) |
AllDeliveredByTick(u64) | All spawned riders reach a terminal state by this tick |
AbandonmentRateBelow(f64) | Abandonment rate is below the threshold (0.0 - 1.0) |
ScenarioRunner::run_to_completion() steps until all riders are delivered/abandoned or max_ticks is reached. You can also call runner.tick() manually for finer control. Check runner.skipped_spawns() if replay fidelity matters – spawn attempts that fail (e.g., referencing removed stops) are counted separately.
Scenarios are Serialize + Deserialize, so you can store them as RON files and load them in CI.
Property-based testing with proptest
The proptest crate (a dev dependency of elevator-core) is used for fuzz-testing simulation invariants. The library’s own tests use it to verify physics and convergence properties:
#![allow(unused)]
fn main() {
use elevator_core::movement::tick_movement;
use proptest::prelude::*;
proptest! {
#[test]
fn tick_movement_never_overshoots(
position in -1000.0..1000.0_f64,
target in -1000.0..1000.0_f64,
max_speed in 0.1..100.0_f64,
acceleration in 0.01..50.0_f64,
deceleration in 0.01..50.0_f64,
dt in 0.001..1.0_f64,
initial_velocity in 0.0..100.0_f64,
) {
prop_assume!((target - position).abs() > 1e-6);
let sign = (target - position).signum();
let vel = initial_velocity.min(max_speed) * sign;
let result = tick_movement(position, vel, target, max_speed, acceleration, deceleration, dt);
// Velocity must never exceed max_speed.
prop_assert!(result.velocity.abs() <= max_speed + 1e-6);
// If not arrived, position must be between start and target.
if !result.arrived {
let min = position.min(target);
let max = position.max(target);
prop_assert!(result.position >= min - 1e-9 && result.position <= max + 1e-9);
}
}
}
}
Good candidates for property-based tests in game code:
- No rider is ever lost: total spawned = delivered + abandoned + still-in-sim.
- Capacity is never exceeded: no elevator’s
current_loadexceeds itsweight_capacity. - Convergence: for any (position, target) pair with valid physics params, the elevator arrives within a bounded number of ticks.
The benchmark suite
The library ships five criterion benchmarks that measure hot-path performance:
| Benchmark | What it measures |
|---|---|
sim_bench | Full simulation tick throughput at various scales |
dispatch_bench | Dispatch strategy comparison (Scan, Look, NearestCar, ETD, Destination) |
scaling_bench | How throughput degrades as stop/elevator count grows |
query_bench | Query iteration performance with filters and extensions |
multi_line_bench | Multi-line/multi-group dispatch overhead |
Run them with:
cargo bench -p elevator-core
Or run a specific benchmark:
cargo bench -p elevator-core --bench sim_bench
Criterion generates HTML reports in target/criterion/ with statistical analysis and comparison against previous runs. These are useful for catching performance regressions when changing dispatch strategies or adding new phases.
Best practices
Seed your RNG for reproducible traffic. The built-in PoissonSource uses an OS-seeded RNG, so it produces different output each run. For tests, write a custom TrafficSource that owns a StdRng::seed_from_u64(seed). See Traffic Generation – Determinism and seeding.
Assert that your fixture actually exercises the sim. A deterministic replay test is vacuously correct if no riders ever board. Add sanity checks:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
fn run(events: Vec<Event>, metrics: &Metrics, expected_minimum: u64) {
assert!(events.iter().any(|e| matches!(e, Event::RiderBoarded { .. })));
assert!(metrics.total_delivered() >= expected_minimum);
}
}
Use SimulationBuilder::demo() for quick integration tests. It creates a minimal two-stop, one-elevator setup that is enough to test most game logic without a full config.
Compare f64 accumulators with .to_bits() for determinism checks. Exact bit equality is the right check because both runs execute the same additions in the same order. Using approximate comparison would mask subtle ordering bugs.
Next steps
- Snapshots and Determinism – the determinism guarantees that make these testing patterns possible.
- Traffic Generation – seeded traffic sources for reproducible test scenarios.
- Performance – interpreting benchmark results and scaling guidance.
Stability and Versioning
elevator-core classifies every public item as stable, experimental, or internal. Each level has a different break-rate guarantee. This page covers what those guarantees mean and how to work with them; the repo’s STABILITY.md is the authoritative source for the per-item classification.
The three status levels
Stable
A stable API ships breaking changes only in planned major versions. Three rules back the guarantee:
- Majors are announced. A major bump that touches a stable API opens a GitHub issue at least 60 days before the release, with a migration note staged in the CHANGELOG preamble.
- Deprecation precedes removal. A stable API on its way out is marked
#[deprecated]for at least one major before deletion. Compiler warnings point you at the replacement. - Semver is honoured strictly. Bug fixes that change behaviour either land behind a new function or wait for the next major.
The current stable surface includes Simulation, SimulationBuilder, Event, SimError, Metrics, the entity IDs (StopId, RiderId, ElevatorId — declared in entity:: and stop::), WorldSnapshot, and the DispatchStrategy trait with all built-in strategies. See STABILITY.md for the full enumeration.
Experimental
Experimental APIs signal the shape is still being discovered. They may break in any minor version with no deprecation cycle. The API is real — fully implemented and tested — but the contract can shift as the design matures.
Currently experimental: hooks::*, topology::*, tagged_metrics::*, scenario::*, traffic::*, query::*, the extension-component APIs on World, the RON config::* surface, movement::* primitives, energy::*, time::TimeAdapter, and ids::* (config-level identifiers like GroupId — distinct from the entity IDs above, which are stable).
If you depend on one of these, pin the minor version:
# Replace X.Y with the minor you are targeting.
elevator-core = "=X.Y"
When you bump the pin, expect a short migration. The CHANGELOG calls out what moved.
Internal
Items marked pub(crate) or #[doc(hidden)] are not part of the supported surface. Anything in systems::*, low-level door/ETA primitives, and #[doc(hidden)] re-exports falls here. Using these from outside the crate (via reflection, macros, or forks) is unsupported.
Non-breaking changes you should expect
Several kinds of change look like API breaks but are deliberately not covered by the stability policy:
- Adding variants to
#[non_exhaustive]enums.EventandSimErrorare non-exhaustive; new variants ship infeat:commits, notfeat!:. - Adding fields with
#[serde(default)]. RON config additions are gated by serde defaults whenever possible; older configs continue to load. - Adding optional builder methods.
SimulationBuilderandRiderBuilderaccept new methods without breaking existing call sites.
These appear in the CHANGELOG under feature additions, not migrations.
Cadence commitment
The stable surface has a bounded break rate; planned majors bundle changes to minimise consumer-side churn. The experimental surface has no such cap. The bound and its scope are spelled out in STABILITY.md § Cadence commitment.
Where to look up an item’s status
Two sources, in order of recency:
- The crate-root module table in the
elevator-corerustdoc. Every module row carries a Stability column; this is the live snapshot. STABILITY.mdin the repo. The History section there records when items graduated from experimental to stable, which the rustdoc table does not.
Next steps
STABILITY.md— full policy text, deprecation rules, graduation history.- Testing Your Simulation — the invariants you can rely on under the stability policy.
- Snapshots and Determinism — the determinism contract that anchors what “stable behaviour” means.
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:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
pub struct ElevatorSimPlugin;
impl Plugin for ElevatorSimPlugin {
fn build(&self, _app: &mut App) { /* wire resources, systems, messages */ }
}
}
When you add this plugin to a Bevy app, it:
- Loads config from a RON file (CLI argument or
assets/config/default.ron) - Creates a
Simulationand inserts it as theSimulationResresource - Inserts
SimSpeedresource for controlling simulation speed - Registers the
EventWrappermessage for bridging sim events to Bevy - Adds systems for ticking the sim, rendering, AI passengers, input, and HUD
Key resources
SimulationRes
The core simulation is wrapped in a Bevy resource:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
#[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:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
#[derive(Resource)]
pub struct SimSpeed {
pub multiplier: u32,
}
}
multiplier: 0– simulation is pausedmultiplier: 1– one tick per frame (normal speed)multiplier: 10– ten ticks per frame (fast forward)
The built-in input system maps keyboard keys to speed changes.
EventWrapper
Core simulation events are bridged into Bevy’s message system:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use elevator_core::events::Event;
#[derive(Message)]
pub struct EventWrapper(pub Event);
}
Bevy systems can read simulation events using MessageReader<EventWrapper>:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use elevator_core::events::Event;
#[derive(Message, Clone)]
pub struct EventWrapper(pub Event);
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:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use elevator_core::events::Event;
use elevator_core::sim::Simulation;
#[derive(Resource)]
pub struct SimulationRes { pub sim: Simulation }
#[derive(Resource)]
pub struct SimSpeed { pub multiplier: u32 }
#[derive(Message, Clone)]
pub struct EventWrapper(pub Event);
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use elevator_bevy::sim_bridge::SimulationRes;
pub struct ElevatorSimPlugin;
impl Plugin for ElevatorSimPlugin { fn build(&self, _: &mut App) {} }
fn print_metrics(_sim: Res<SimulationRes>) {}
let mut app = App::new();
app.add_plugins(ElevatorSimPlugin)
.add_systems(Update, print_metrics);
}
Module layout
The elevator-bevy crate is organized into focused modules:
| Module | Responsibility |
|---|---|
plugin.rs | ElevatorSimPlugin – loads config, creates sim, registers everything |
sim_bridge.rs | SimulationRes, SimSpeed, EventWrapper, tick system |
rendering.rs | 2D visualization of the building, elevators, and riders |
ui.rs | HUD overlay showing metrics and simulation state |
camera.rs | Camera setup sized to the building |
input.rs | Keyboard controls for speed adjustment |
passenger_ai.rs | Timer-based passenger spawning |
When to use elevator-bevy vs. building your own
Use elevator-bevy if you want a quick visual test of your dispatch strategy or config. Run it, watch the elevators move, tweak parameters.
Build your own if you are making a game. The Bevy crate is intentionally simple – it is a reference, not a framework. Copy the patterns you need (the SimulationRes resource, the tick system, the event bridge) into your own Bevy app and build your game systems around them.
The core library does not depend on Bevy at all. You can use it with any Rust game engine, a TUI, a web frontend via WASM, or pure headless batch simulation. See Headless and Non-Bevy Usage for engine-agnostic integration patterns.
Next steps
- Headless and Non-Bevy Usage – integrating elevator-core without Bevy, including macroquad and eframe patterns.
- Events and Metrics – the
Eventenum and metric accumulators that power the HUD and your custom systems. - Performance – throughput baselines and scaling guidance for choosing a tick rate.
Headless and Non-Bevy Usage
elevator-core is engine-agnostic. The elevator-bevy crate is one reference integration; it’s the visual debugger that ships in this repository. But nothing in elevator-core itself depends on Bevy – you can drop the library into macroquad, eframe/egui, a web backend, a CLI analysis tool, or a pure headless driver.
This chapter walks through the integration surface and shows three concrete patterns.
The integration contract
Integrating elevator-core into any host comes down to three things:
-
Build the
Simulationonce, up front.SimulationBuilder::new()orSimulationBuilder::from_config(config)then.build(). Keep theSimulationas state in your engine’s scene / app struct / actor. -
Drive the tick loop. Call
sim.step()each frame (or on a fixed-timestep accumulator, if you want to decouple sim rate from render rate).Simulation::step()only reads and writes the internal world state – no I/O, no wall-clock dependency, no engine-specific globals. -
Read state out, inject input in.
- Read state:
sim.world()returns aWorldyou can query viaquery::<(EntityId, &Rider, &Position)>()for rendering, or via typed accessors (world.elevator(id),world.stop_position(id)). - Inject input:
sim.spawn_rider(origin, dest, weight),sim.push_destination(elev, stop),sim.abort_movement(elev),sim.reroute(rider, new_dest),sim.set_service_mode(elev, mode). - Change-event hook:
sim.drain_events()returns every event emitted during the last tick. Route them into toasts, particles, SFX, analytics.
- Read state:
That’s it. The entire public surface of the library is the prelude module (see docs.rs) plus 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:
#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
use elevator_core::__doctest_prelude::*;
use std::io::Write;
fn run(sim: &mut Simulation, out: &mut impl Write, ticks: u64) -> Result<(), Box<dyn std::error::Error>> {
for _ in 0..ticks {
sim.step();
for event in sim.drain_events() {
let line = serde_json::to_string(&event)?;
writeln!(out, "{line}")?;
}
}
Ok(())
}
}
Event implements Serialize / Deserialize, so consumers in any language can read the NDJSON stream. This is the integration shape a web backend would use: stream events over SSE / WebSocket, have a JS frontend render them.
Pattern 2 – macroquad (game loop)
macroquad is a lightweight cross-platform game framework with a simple async fn main game loop. The integration pattern is about 200 lines – most of the code is rendering, not elevator-core glue.
Advisory note. At the time of writing, macroquad 0.4.x carries RUSTSEC-2025-0035 (unsound mutable-static use; no fixed version available). We don’t ship a runnable example to keep this repository’s
cargo-denysupply-chain check green. The code sketch below is correct and will run if you addmacroquad = "0.4"as a dependency in your own crate.
The relevant integration pattern:
use elevator_core::components::{Elevator, RiderPhase, Stop};
use elevator_core::prelude::*;
use macroquad::prelude::*;
#[macroquad::main(window_conf)]
async fn main() {
let mut sim = build_sim(); // 1. Build once
loop {
if is_key_pressed(KeyCode::Space) {
spawn_random_rider(&mut sim); // 3b. Inject input
}
sim.step(); // 2. Drive the tick
let _events = sim.drain_events(); // 3c. Consume events
clear_background(BLACK);
draw_shaft(&sim); // 3a. Read state
draw_elevators(&sim);
draw_hud(&sim);
next_frame().await;
}
}
fn draw_elevators(sim: &Simulation) {
for (_, pos, car) in sim.world()
.query::<(EntityId, &Position, &Elevator)>()
.iter()
{
let y = position_to_screen_y(pos.value());
let color = if car.current_load().value() > 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().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(StopId(0), StopId(3), 72.0);
}
});
}
}
fn main() -> eframe::Result<()> {
eframe::run_native(
"elevator-core",
eframe::NativeOptions::default(),
Box::new(|_| Ok(Box::new(ElevatorApp {
sim: build_sim(),
tick_per_frame: true,
}))),
)
}
Everything above except the eframe::App trait impl is standard elevator-core usage. The only engine-specific concept is ctx.request_repaint() to keep the UI ticking when the sim is running.
Picking a pattern
| Host | When it’s the right fit |
|---|---|
| headless / CLI | Analysis, batch runs, CI, web backends (stream Events over SSE / WebSocket to a JS frontend). |
| macroquad | 2D games, rapid iteration, WASM browser builds. Minimal dep footprint. |
| eframe / egui | Dashboards, inspectors, debuggers. Good when you want live-editable sim state with sliders + buttons. |
| Bevy | Full 3D games, ECS-native integration, complex scene systems. See Bevy Integration. |
| Wasm-in-browser | Any of the above, as long as the traffic feature (which uses rand::rngs::ThreadRng by default) is either disabled or paired with a seeded StdRng via PoissonSource::with_rng. |
Determinism across hosts
elevator-core is deterministic: same config + same sequence of inputs produce identical event streams across hosts. If your renderer needs to replay a saved scenario, combine WorldSnapshot::restore() (from Snapshots and Determinism) with a seeded StdRng on any PoissonSource – the tick loop itself has no internal randomness.
Next steps
- Snapshots and Determinism – round-trip save/load so integrations can persist simulation state.
- Events and Metrics – the
Eventenum and metric accumulators that drive UI updates. - Performance – throughput baselines and scaling guidance for choosing a tick rate.
TUI Debugger
elevator-tui is a terminal UI debugger for the simulation. It runs in
two modes from one binary:
- Interactive – a live viewer with shaft column, scrolling event log, dispatch summary, and metrics panel. Pause and step tick-by-tick while you inspect what changed.
- Headless – step the sim for N ticks, print a metrics summary,
optionally emit the full event stream as JSON. Suitable for CI smoke
tests against every config in
assets/config/and for capturing a reproducible event trace to attach to a bug report.
The TUI complements the existing surfaces: the Bevy demo cannot pause the sim cleanly, and the playground summarises events at a high level. For “why did this car make that decision on tick 4218” debugging, the TUI is the cheapest tool.
Quick start
# Interactive mode against the default scenario.
cargo run -p elevator-tui -- assets/config/default.ron
# Headless smoke run -- step 5000 ticks, print summary, exit.
cargo run -p elevator-tui -- assets/config/default.ron --headless --until 5000
# Headless run with full event capture for bug repro.
cargo run -p elevator-tui --release -- \
assets/config/default.ron --headless --until 10000 --emit trace.json
The binary takes one positional argument (the RON config path) plus flags:
| Flag | Default | Purpose |
|---|---|---|
--headless | off | Run non-interactively and print a summary instead of a TUI |
--until <N> | 1000 | (headless) absolute tick to stop at |
--emit <PATH> | – | (headless) write drained events as JSON |
--no-traffic | off | (headless) disable Poisson rider spawning |
--tick-rate <FACTOR> | 1.0 | (interactive) initial multiplier on config tick rate |
Layout
The interactive view is one shaft column on the left and a stacked right column with three panels: events (top), dispatch summary (middle), metrics (bottom).
elevator-tui tick 842 RUNNING rate 2.00x shaft Index
+--shaft-(3-cars)--+--overview-----------------------------------------+
| Top 10.0 | . . . | events . filter [all] |
| ... | t=842 ElevArrived e=2v1 at=12v1 |
| F02 4.0 | . . . | t=842 DoorOpened e=2v1 |
| Lobby 0.0 | A . . | t=841 RiderBoarded r=17v1 e=1v1 |
| | ... |
| |--------------------------------------------------|
| | dispatch |
| | Default strategy=Scan cars=3 waiting=4 |
| | EntityId(2v1) phase=Loading queue=1 |
| |--------------------------------------------------|
| | metrics |
| | spawned 19 delivered 12 abandoned 0 (0.0%) |
| | wait avg 32.1t p95 84t max 142t |
+---------------------+--------------------------------------------------+
space pause . step , step*10 +/- rate m shaft []car f follow ...
The shaft has two modes you can toggle at runtime with m:
- Index (default): one row per stop, regardless of distance. The
car glyph appears at the stop closest to its current position, with
an arrow indicating direction (
▲up,▼down). Compact for tall buildings; doesn’t honour non-uniform stop spacing. - Distance: rows are scaled to actual stop positions, so a car
drifting between two stops 80 km apart sits visibly mid-cable.
Honours
space_elevator.ron-class scenarios at the cost of vertical space when stops are clumped.
Hotkeys
Tick control
| Key | Action |
|---|---|
space | Pause / resume auto-stepping |
. | Single-step one tick (works while paused) |
, | Step 10 ticks |
+, = | Double the tick rate (cap 64x) |
-, _ | Halve the tick rate (floor 0.0625x) |
Layout
| Key | Action |
|---|---|
m | Cycle shaft mode (Index <-> Distance) |
], [ | Focus next / previous car |
f | Toggle follow mode – filters the events panel to the focused car |
Enter | Toggle the per-car drill-down panel for the focused car |
Esc | Close drill-down (no-op when already closed) |
Event filtering
| Key | Toggle category |
|---|---|
1 | Elevator |
2 | Rider |
3 | Dispatch |
4 | Topology |
5 | Reposition |
6 | Direction |
7 | Observability |
Snapshot
| Key | Action |
|---|---|
s | Save the current sim state to an in-memory slot |
l | Restore the saved sim state (clears event/sparkline) |
Quit
| Key | Action |
|---|---|
q | Quit |
Ctrl-C | Quit |
Debugging recipes
“Why did this car bypass that floor?”
- Press
spaceto pause as soon as you see the car about to skip. - Press
[/]until the car is focused (its glyph reverses). - Press
Enterto open the drill-down – the destination queue reveals what stops dispatch put on its route. - Press
fto filter events to the car – you can scroll back throughAssigned,PassingFloor, and door events to see the bypass decision in context. - Press
sto save the snapshot at the suspicious tick. Pressllater to come back to this exact moment.
Reproducing a bug from a headless trace
cargo run -p elevator-tui --release -- \
assets/config/your_scenario.ron --headless --until 8000 --emit trace.json
Attach trace.json to the bug report. Each entry is { tick, event }
in drain order, so a future replay tool (or a one-off jq query) can
reconstruct what happened tick-by-tick.
CI smoke test
A non-zero exit means construction failed – the simulation refused the config – so the pattern below catches schema regressions across every shipped scenario:
for cfg in assets/config/*.ron; do
cargo run -q -p elevator-tui --release -- "$cfg" --headless --until 5000
done
Architecture notes
The TUI is a pure consumer of the public Simulation API. Every
method it calls is enumerated in bindings.toml under the tui
column – look there to see exactly what the read-only viewer touches
and what is intentionally skipped (the v1 viewer is read-only and
does not expose mutators). Adding new TUI panels generally means
flipping a tui = "skip:..." to a tui = "<panel_name>".
The interactive renderer is ratatui + crossterm. The frame budget
is ~33 ms (about 30 fps); inside one frame the loop polls input, then
auto-advances the sim by however many ticks fit in tick_rate × ticks_per_second × elapsed_wall_time. A soft cap prevents a high
rate from spinning the loop for whole seconds at a stretch on slow
terminals.
State is split between state.rs (pure data, unit-testable without a
real Simulation) and app.rs (terminal I/O + sim driving). The
render layer (ui/) only reads from the state and a borrowed
&Simulation – it never mutates either.
Next steps
- The viewer is intentionally read-only. If you want to spawn riders, change strategies, or pin assignments interactively, those would land in a follow-up controller mode.
- For richer event introspection (regex search, structured query), the event log could grow a slash-command bar – not needed yet.
- See Snapshots and Determinism for the
semantics of the
s/lhotkeys and what restoration preserves.
Using the Bindings
elevator-core is a Rust library, but the simulation is also exposed through several non-Rust binding crates so games and tools written in other languages can drive it without a Rust toolchain in their build pipeline:
| Crate | Surface | Audience |
|---|---|---|
elevator-wasm | wasm-bindgen exports + auto-generated TypeScript types | Browser playgrounds, JS/TS games, web dashboards |
elevator-ffi | C ABI shared library + auto-generated elevator_ffi.h | Unity (P/Invoke), .NET, native C/C++ harnesses |
elevator-gdext | gdext-registered Godot Node (ElevatorSim) | Godot games / GDScript prototypes |
| GameMaker GML wrapper | Generated GML scripts wrapping elevator-ffi | GameMaker Studio 2 projects |
All bindings share the same simulation core: same physics, same dispatch, same event ordering. The split is purely about how the host language calls in. This chapter covers the contracts each binding exposes and the patterns consumers need to follow to use them safely.
Picking a host
If you already know which language / engine you’re building in, the table above is a one-step decision. The matrix below maps common starting points to the right binding when the choice isn’t obvious:
| You’re building… | Use | Why |
|---|---|---|
| A Rust game with a renderer | Bevy Integration or Headless / macroquad / eframe | Direct Rust API, no binding layer; lowest friction. |
| A browser playground or JS/TS frontend | elevator-wasm | TypeScript types auto-generated; no Rust toolchain on the consumer side. |
| A Unity, .NET, or native C/C++ project | elevator-ffi | C ABI shared library plus generated elevator_ffi.h; P/Invoke or direct linking. |
| A Godot game (GDScript or GDExtension) | elevator-gdext | Registers an ElevatorSim node; calls return Godot Variant dictionaries. |
| A GameMaker Studio 2 game | GameMaker GML wrapper | Pre-generated GML scripts wrap elevator-ffi so the GMS project sees idiomatic functions. |
| An analytics / batch / replay tool | Use elevator-core directly | The Rust crate is headless. The wasm and FFI surfaces exist for non-Rust consumers; reach for them only when language is the constraint. |
Each binding is a thin translation layer — there’s no behavioral difference between simulating in Rust vs. via wasm vs. via FFI given the same config and inputs. Pick by language ergonomics, not by feature set.
Coverage and stability
The set of Simulation methods exposed through each binding is enumerated in bindings.toml at the workspace root. CI verifies the file is in sync with pub fn declarations, so a new core method cannot ship without an explicit decision about whether it is bound and how.
Three statuses are valid per cell (one column per host: wasm, ffi, tui, gms, gdext, bevy):
- An exported name (
stepMany,ev_sim_step) means the method is bound under that name. skip:<reason>means the method is intentionally not bound. The reason is mandatory and documents why (lifetime, internal detail, covered by another binding, etc.).todo:<phase>is a temporary status during phased rollout.
At the time of this chapter the wasm and FFI columns are at full coverage of the public surface; the gdext and bevy columns carry todo: markers for methods queued under the future-binding and plugin-layer phases respectively (see Binding Coverage Manifest for the per-phase breakdown). Any future additions to Simulation will surface there before they ship.
elevator-wasm
The wasm binding is built once, copied into your project, and consumed as ES modules. The TypeScript types are auto-generated by tsify-next from the Rust DTOs, so adding a field on the Rust side propagates to TS without hand-editing.
Building and consuming
The build script in this repository wraps wasm-pack and wasm-bindgen-cli with the right targets:
scripts/build-wasm.sh
This produces a pkg/ directory under playground/public/ containing:
elevator_wasm.js— the JS shimelevator_wasm_bg.wasm— the compiled moduleelevator_wasm.d.ts— TypeScript types for every DTO
For a non-playground consumer, copy pkg/ into your project and import the constructor:
import init, { WasmSim } from "./pkg/elevator_wasm.js";
await init();
const sim = WasmSim.fromConfigJson(myConfigJson);
for (let i = 0; i < 60; i++) {
sim.stepMany(1);
}
const snapshot = sim.snapshot();
console.log(`tick ${snapshot.tick}, ${snapshot.cars.length} cars`);
DTO shape and naming
Every method that returns structured data returns a tsify-generated DTO. The naming convention is *Dto on the Rust side, and the same struct name without the Dto suffix on the TS side is not used — TypeScript consumers see the full Rust name (CarDto, StopDto, MetricsDto, RouteDto, HallCallDto, CarCallDto, TaggedMetricDto, EventDto).
Method names are camelCase via #[wasm_bindgen(js_name = ...)]. Every getter has the same shape on both sides:
| Rust | TypeScript |
|---|---|
sim.snapshot() | sim.snapshot() |
sim.metrics_for_tag("vip") | sim.metricsForTag("vip") |
sim.shortest_route(from, to) | sim.shortestRoute(from, to) |
Errors
Most fallible methods return a Result-shaped object — a discriminated union with a string kind discriminator — instead of throwing. Three concrete result types cover the surface:
| Type | Used by |
|---|---|
WasmVoidResult | Mutators that return () on success (most methods) |
WasmU64Result | Methods that return an entity id |
WasmU32Result | Methods that return a count or code |
Each materializes on the TS side as:
type WasmU64Result =
| { kind: "ok"; value: bigint }
| { kind: "err"; error: string }
Usage:
const r = sim.spawnRider(originId, destId, 75);
if (r.kind === "err") {
console.error("spawn failed:", r.error);
}
The string discriminator narrows r.value and r.error per branch without a manual cast. There is no null / undefined “error sentinel” pattern to special-case.
A small set of methods still throw rather than returning a Result-shape:
- The
WasmSimconstructor (matches the JS-idiomatic “constructors throw” pattern) sim.assignedCarsByLine(stop, direction)andsim.bestEta(stop, direction)— these returnbigint[]and reusing the Result-shape would require introducing a fourth result type for one call site each. They throw on bad direction strings; wrap intry/catchif you can’t statically guarantee"up" / "down".
Events
sim.drainEvents() returns an array of EventDto objects. Each has a kind discriminator ("rider-spawned", "door-opened", etc., kebab-case) plus the variant-specific fields:
for (const ev of sim.drainEvents()) {
switch (ev.kind) {
case "rider-spawned":
console.log(`rider ${ev.rider} spawned at stop ${ev.origin}`);
break;
case "door-opened":
playSfx("door-open", ev.elevator);
break;
case "unknown":
// Forward-compat: future Event variants surface here.
break;
}
}
The TypeScript discriminated union is the auto-generated type, so the compiler narrows fields per kind without manual casting.
elevator-ffi
The FFI binding is a cdylib produced by cargo build -p elevator-ffi --release and a C header at crates/elevator-ffi/include/elevator_ffi.h regenerated automatically by cbindgen on every build. CI guards against header drift.
The C-side public API is small and uniform: every entry point starts with ev_sim_*, takes an EvSim* handle as the first argument, and returns an EvStatus code. The exception is constructors (ev_sim_create*) which return the handle directly (or null on failure).
ABI version handshake
Every shared library carries an integer ABI version. Consumers MUST check it on startup and refuse to proceed if it doesn’t match the version they were built against:
#include "elevator_ffi.h"
if (ev_abi_version() != EV_ABI_VERSION) {
fprintf(stderr, "elevator-ffi ABI mismatch: lib %u, header %u\n",
ev_abi_version(), EV_ABI_VERSION);
return 1;
}
The constant in the header (EV_ABI_VERSION) bumps on every layout change to a repr(C) struct or any other breaking change to the C surface. A bump is a feat!: commit and triggers a major version bump on elevator-ffi itself; consumers see it as a SemVer-major rebuild signal.
Status codes
Every fallible function returns EvStatus:
| Code | Meaning |
|---|---|
EvStatusOk | Success |
EvStatusNullArg | A required pointer argument was null |
EvStatusInvalidUtf8 | A C string argument was not valid UTF-8 |
EvStatusConfigLoad / EvStatusConfigParse | Config file I/O / parse failure |
EvStatusBuildFailed | SimulationBuilder::build returned an error |
EvStatusNotFound | Entity, group, or resource not found |
EvStatusInvalidArg | Argument structurally valid but semantically rejected |
EvStatusPanic | A Rust panic was caught at the FFI boundary |
On any non-Ok status, ev_last_error() returns a thread-local null-terminated description. The pointer is valid until the next FFI call on the same thread:
EvStatus s = ev_sim_step(sim);
if (s != EvStatusOk) {
fprintf(stderr, "step failed: %s\n", ev_last_error());
}
The buffer pattern for variable-length output
C has no native dynamic arrays, so any FFI export that returns a list takes a caller-owned buffer with explicit capacity. Two-step usage: probe to size, then allocate and read:
// Step 1 — probe with capacity 0 to learn the required size.
uint32_t needed = 0;
EvStatus s = ev_sim_shortest_route(sim, from, to, NULL, 0, &needed);
if (s == EvStatusNotFound) {
// No route exists.
return;
}
// `needed` is now the slot count.
// Step 2 — allocate, read, use.
uint64_t* stops = malloc(needed * sizeof(uint64_t));
uint32_t written = 0;
s = ev_sim_shortest_route(sim, from, to, stops, needed, &written);
assert(s == EvStatusOk && written == needed);
for (uint32_t i = 0; i < written; i++) {
/* ... handle stops[i] ... */
}
free(stops);
The same pattern applies to ev_sim_hall_calls_snapshot, ev_sim_car_calls_snapshot, ev_sim_drain_events, ev_sim_destination_queue, the population queries (ev_sim_waiting_at, ev_sim_residents_at, ev_sim_abandoned_at), and ev_sim_all_tags.
Sentinel encoding for optional values
Where the Rust API returns Option<T>, the FFI flat layout uses sentinels chosen to round-trip safely:
| Rust type | FFI sentinel for None |
|---|---|
Option<f64> | f64::NAN (test with isnan()) |
Option<u64> | 0 (entity ids never use slot 0) |
Option<bool> | false |
These are the same sentinels used inside the engine’s own repr(C) stuctures (e.g. EvElevatorParams.bypass_load_up_pct), so the encoding is uniform across input parameters and output buffers.
EvEvent and the event mirror
ev_sim_drain_events writes a sequence of EvEvent records into a caller-owned buffer. Each record has a kind discriminator and 13 payload fields shared by every event kind; ev_event_kind provides named constants for every variant the Rust core emits.
The struct is wide on purpose — the wasm side hands consumers a discriminated union with variant-specific fields, while the FFI side keeps every event the same shape so a C consumer can iterate over a single typed array. Field semantics depend on kind; for example:
EvEvent buf[256];
uint32_t written = 0;
ev_sim_drain_events(sim, buf, 256, &written);
for (uint32_t i = 0; i < written; i++) {
EvEvent* e = &buf[i];
switch (e->kind) {
case ev_event_kind_RIDER_SPAWNED:
printf("rider %llu at stop %llu, dest %llu\n", e->rider, e->stop, e->floor);
break;
case ev_event_kind_DOOR_COMMAND_QUEUED:
// code1 = command (Open / Close / HoldOpen / CancelHold)
// count = hold ticks for HoldOpen, 0 otherwise
printf("door cmd %u on car %llu, hold %llu\n", e->code1, e->car, e->count);
break;
case ev_event_kind_UNKNOWN:
// Forward-compat: future variants surface here.
break;
}
}
The full per-kind field map lives in the rustdoc on ev_event_kind.
Thread-safety
Each EvSim handle is not Sync. Callers must serialize all calls on a single handle. Multiple handles created from ev_sim_create are independent and may be driven from different threads.
ev_last_error is thread-local — different threads see different last-error strings. This means a status check must happen on the same thread that made the call.
Memory ownership
EvSim*handles are owned by the caller. Create withev_sim_create(orev_sim_create_from_config); destroy withev_sim_destroy. Failing to destroy leaks the underlying simulation.ev_sim_framereturns pointers into an internal buffer owned by the handle. Those pointers are valid only until the next call toev_sim_frameon the same handle. Do not retain them across frames; copy what you need.- The string from
ev_last_erroris valid only until the next FFI call on the same thread.
elevator-gdext
The gdext binding registers an ElevatorSim node that GDScript can use directly — no FFI ceremony, no manual handle management. Build with cargo build -p elevator-gdext --release; the resulting cdylib is loaded by Godot via the standard .gdextension manifest.
ABI handshake against elevator-ffi
elevator-gdext is pinned against a specific elevator-ffi ABI generation. The pin is exposed as a public constant (ABI_VERSION) and watched by scripts/check-abi-pins.sh in CI: any drift between this constant and EV_ABI_VERSION in the FFI header fails the gate, surfacing a stale gdext pin before runtime. GDScript callers that want to verify the binding’s ABI generation can read the constant via the registered Node API.
Node API
ElevatorSim is a Godot Node subclass. Attach it to your scene tree, set the exported properties, and the sim ticks itself in _process:
extends Node
@export var elevator: ElevatorSim
func _ready():
elevator.config_path = "/path/to/default.ron"
elevator.speed_multiplier = 1
elevator.auto_spawn = true
| Exported property | Type | Description |
|---|---|---|
config_path | String | Filesystem path to a RON config |
speed_multiplier | i32 | Sim steps per frame (0 = paused) |
auto_spawn | bool | Whether to auto-spawn riders |
spawn_interval_ticks | i32 | Mean ticks between auto-spawns |
weight_min | f64 | Minimum auto-spawn rider weight |
weight_max | f64 | Maximum auto-spawn rider weight |
Methods exposed to GDScript fall into a small set of groups (full per-method coverage lives in bindings.toml):
- Rider management —
spawn_rider,spawn_rider_ex,despawn_rider - Strategy —
set_strategy - Per-frame reads —
current_tick,stop_count,elevator_count,rider_count,get_stop,get_elevator,get_rider,get_metrics,eta_to_stop,drain_events
The set is intentionally narrower than wasm/FFI: methods that have no idiomatic GDScript shape (Option<T> returns, raw byte snapshots, custom dispatch trait objects) are listed as todo:future-binding in bindings.toml. Picking up a future-binding row is the way to broaden gdext coverage.
Memory ownership
The Godot scene tree owns the ElevatorSim node; the underlying Simulation is dropped automatically when the node leaves the tree. There is no caller-side destroy call — adopting the gdext binding into an existing C-style flow generally means letting Godot manage lifetimes.
elevator-ffi-gms (GameMaker Studio 2 wrapper)
The GameMaker binding is a thin GML wrapper around elevator-ffi. The C entry points (ev_sim_*) are declared via external_define and exposed to GML under the same names; the codegen step in crates/elevator-layout-codegen emits the matching GML record-readers from the same MultiHostLayout metadata that drives the Unity / .NET shapes.
The GMS column in bindings.toml typically mirrors the FFI column 1:1 — when an ev_sim_* is bound under FFI, the GML wrapper exists. The few skip: entries on GMS exist where GameMaker’s type system can’t represent the underlying shape (e.g. raw byte buffers).
A worked example, calling ev_sim_step from GameMaker:
// In a Create event, after external_define has registered ev_sim_step:
var status = ev_sim_step(global.sim_handle);
if (status != 0) {
show_debug_message("step failed: " + ev_last_error());
}
Because the GML side calls into elevator-ffi directly, the same memory-ownership and thread-safety rules from the FFI section apply unchanged.
Common patterns across both bindings
Some idioms apply to both surfaces:
-
Build once, drive many ticks. The simulation handle is a heavy object; create it at startup and keep it for the lifetime of the application.
stepandstepManyare designed to be called every frame. -
Drain events every tick. Both bindings emit events into an internal queue; if you don’t drain, the queue grows unbounded. The drain operations are non-blocking — call them after every step in your tick loop.
-
Entity ids are stable across ticks but not across simulation builds. Save them as numbers between calls, but don’t persist them across
ev_sim_destroy/WasmSimrebuilds. -
Snapshots round-trip the full simulation state. Both bindings can serialize a snapshot to bytes, ship it across a network or persist it to disk, then restore it later. See Snapshots and Determinism for the determinism guarantees.
Next steps
- Headless and Non-Bevy Usage — the same integration patterns from the Rust side; useful for understanding what each binding’s exports map back to.
- Events and Metrics — the
Eventenum and metric accumulators that drive UI updates in either binding. - Stability and Versioning — how API breaks are signaled across crates, and what the ABI version handshake protects against.
Binding Coverage Manifest
This page documents bindings.toml — the workspace-root manifest that
records, for every public method on impl Simulation, whether and how
each binding crate (FFI, wasm, gdext, Bevy, TUI, GMS) exposes it. CI
enforces the file via scripts/check-bindings.sh; an unlisted method or
a stale entry fails the workspace build.
Why it exists
The core crate is the source of truth, and several host crates wrap it.
Without a single contract, “fully supported in language X” drifts
silently — a new core method ships, no binding picks it up, and consumers
have to discover the gap by trying it. bindings.toml makes that
decision explicit: every pub fn on Simulation either has a binding
under each host or has a recorded reason for not having one.
It is intentionally a coverage manifest, not a generator. Bindings are still hand-written so each host can shape its idiomatic surface; the manifest exists only to prevent silent drift.
For non-method host concerns — error marshalling, log-drain semantics, ABI / wire version, snapshot field-set parity — see Host Binding Parity, the cross-host contract that complements this manifest.
Taxonomy
Each entry is keyed by Rust method name and lists one status per host
column (wasm, ffi, tui, gms, gdext, bevy). Three status
shapes are accepted:
| Status | Meaning |
|---|---|
<exported-name> | Bound. Value is the host-facing name (e.g. stepMany, ev_sim_step, the GDScript callable, the TUI panel that uses it). |
skip:<reason> | Intentionally not bound. The reason is mandatory and must explain why — lifetimes, internal detail, covered by a different binding, read-only viewer, etc. |
todo:<phase> | Planned for a named phase. CI accepts it (warning, not error); once that phase ships the entry flips to either an exported name or a skip. |
Two phase markers are currently in use:
plugin-layer— used only in thebevycolumn. Until a Bevy plugin layer ships, every non-internalmethod’sbevyslot carries this marker. It is the expected state, not an actionable gap.future-binding— used only in thegdextcolumn. These are the real “pick this up next” queue entries.
The check script breaks these out so future-binding work doesn’t get
lost in plugin-layer noise.
Categories
Every entry also carries a category field, which groups related
methods so the manifest stays scannable as it grows. Definitions live in
[categories] at the top of bindings.toml. The most useful ones to
recognize when adding new methods:
lifecycle— construction, ticking, run-loops.dispatch— strategy swap, pinning, ETA queries.riders,routes,topology,buttons— domain mutations.introspection— read-only world queries.parameters— runtime tuning of speed/capacity/door timings.events,metrics,hooks,tagging— observability.internal— methods that return&World/&mut Worldor other internal slices and should never be bound.
Choose the category that matches what the method does for the host, not the file it lives in.
Workflow
Adding a new Simulation method
- Land the implementation in
crates/elevator-core/src/sim.rs(orsrc/sim/*.rs). - In the same PR, add a new
[[methods]]entry tobindings.tomlalphabetized within its category section. - Fill every host column. The default for a new method is usually
todo:future-binding(gdext) andtodo:plugin-layer(bevy); forwasm/ffi/gmsyou must either bind it now or write askip:reason. - Run
scripts/check-bindings.shlocally — it’s also part of the pre-commit hook.
Renaming a method
The manifest is keyed on the Rust method name. Renaming is a two-line
edit: update the name field and (if the rename changes the host
binding name) update each host’s exported name. CI catches the
rename-without-update case as a STALE failure.
Removing a method
Delete both the implementation and the manifest entry. CI fails on
STALE entries (manifest references a method that no longer exists),
which is the prompt to clean up.
Choosing skip vs todo
- Use
skip:<reason>when the method cannot be bound under that host — borrows internal state, exposes lifetimes a host can’t model, or is superseded by a different exported surface (e.g. wasm prefers a flattened DTO). The reason must read clearly enough that a future reader doesn’t think it’s a forgotten gap. - Use
todo:<phase>when binding is deferred, not refused. The phase string is what tells reviewers when to expect coverage.
If the answer is “we just haven’t decided”, that’s a todo: until the
decision is made.
Worked example
A new method Simulation::set_floor_pressure(&mut self, stop: StopId, n: u32)
ships in sim.rs. The corresponding manifest entry:
[[methods]]
name = "set_floor_pressure"
category = "parameters"
wasm = "setFloorPressure"
ffi = "ev_sim_set_floor_pressure"
tui = "skip:read-only viewer"
gms = "ev_sim_set_floor_pressure"
gdext = "todo:future-binding"
bevy = "todo:plugin-layer"
Reading left-to-right: bound under wasm, FFI, and GameMaker; skipped in the TUI because the TUI is a read-only viewer; queued for gdext under the standard future-binding phase; queued for bevy under the standard plugin-layer phase.
Related
bindings.toml— the manifest itself, with the current header comment kept in sync with this page.scripts/check-bindings.sh— the CI gate.- Using the Bindings — host-facing usage docs.
Next steps
- Read Using the Bindings for hands-on usage of each host crate.
- Browse
bindings.tomlto see the current state of every method × host pair, and use thefuture-bindingfilter to find work to pick up.
Host Binding Parity
This page is the cross-host contract for elevator-core’s binding crates: the non-method surfaces that every host (FFI, wasm, gdext, Bevy, GameMaker) is expected to expose, and the agreed semantics for each. It is the counterpart to the Binding Coverage Manifest — the manifest tracks every Simulation::* method one-by-one; this page tracks the snapshot, event, error, and ABI-version concerns that don’t live on Simulation but still have to align across hosts.
The parity surface
| Concern | Source of truth | FFI | wasm | gdext | Bevy | Notes |
|---|---|---|---|---|---|---|
| Snapshot encode | Simulation::snapshot | EvSnapshot (#[repr(C)]) | Snapshot (Tsify) | Dictionary | SimSnapshot resource | Fields must align — adding a field to the core snapshot requires updating every host. |
| Event drain (consume) | Simulation::drain_events | ev_sim_drain_events | drainEvents | drain_events | EventWrapper messages | All four route through Simulation::drain_events. |
| Event peek (non-consuming) | Simulation::pending_events | (internal, used by log forwarder) | pendingEvents | (none yet) | (none yet) | gdext / Bevy parity is a follow-up. |
| Log drain (formatted) | events::log_format::format_event | ev_drain_log_messages | peekLogMessages (#656) | peek_log_messages (#656) | skip — uses tracing | Severity constants in events::log_format. |
| Error marshalling | host_error::ErrorKind | EvStatus (From<ErrorKind>) + ev_last_error | thrown Error | Godot exception | Rust panic | Shared classification lives in elevator_core::host_error; FFI maps it to EvStatus. wasm / gdext consume ErrorKind::label() for kebab-case classification strings. |
| ABI / wire version | elevator_core::HOST_PROTOCOL_VERSION | EV_ABI_VERSION (literal, asserted equal to core) | ABI_VERSION (refs core) | ABI_VERSION (refs core) | crate semver | FFI keeps a literal so cbindgen can emit #define EV_ABI_VERSION in the generated C header; a compile-time assert! ties the literal to core. |
Error vocabulary
The FFI’s EvStatus enum classifies failure modes in a way that
non-FFI hosts also need (e.g. which kind of error did the wasm
binding throw?). The intended classification:
Ok— success.NullArg— required pointer / handle was null.InvalidArg— argument is not null but is malformed (bad utf-8, invalid entity id, out-of-range value).NotFound— referenced entity does not exist (or has been removed) at the time of the call.Capacity— operation would exceed a configured limit (rider weight, line size, …).Panic— internal panic recovered at the host boundary; the callable is unsafe to retry without recreating the handle.
Today only the FFI lifts this vocabulary into a typed enum. Future PRs will lift these to a shared module so wasm / gdext error constructors map their underlying language errors onto the same classification.
Next steps
- Binding Coverage Manifest — the per-method coverage view that complements this page.
- Using the Bindings — host-by-host consumer guide; cross-reference the parity table above for known gaps in the host you ship against.
- Supporting Crates — the build-time crates (
elevator-layout-*,elevator-contract) that enforce the parity contract in CI.
History
The contract above is the result of incremental work across several PRs, originally tracked under issue #655 (“Add HostBinding abstraction trait shared across binding crates”). The umbrella issue is closed; further cross-host parity changes edit this page directly instead of opening a new umbrella.
Migration plan (all complete)
The work was intentionally incremental — each step shipped independently and kept every host runnable.
- Shared log severity constants —
LEVEL_TRACE…LEVEL_ERRORlifted from FFI’s hardcoded values intoelevator_core::events::log_format. Hosts that surface formatted log records reference the constants instead of knowing “1 means debug” out-of-band. - Cross-host log drain (#656) — wasm and gdext expose
peekLogMessages/peek_log_messagesmirroring FFI’sev_drain_log_messages. Bevy is intentionally skipped because it has nativetracing. - Shared error classification —
elevator_core::host_error::ErrorKindis the cross-host failure vocabulary (NullArg,InvalidUtf8,ConfigLoad,ConfigParse,BuildFailed,NotFound,InvalidArg,Panic). FFI providesimpl From<ErrorKind> for EvStatus; new FFI / wasm / gdext call sites should produce errors via the shared kind so the integer / string / Variant representations stay aligned. Adoption across existing call sites is intentionally incremental — the shared enum is the foothold, not a flag-day migration. - Snapshot field-set guard — a tripwire test (
elevator_ffi::tests::snapshot_dto_field_names_locked) locks the field names on every snapshot DTO (EvElevatorView,EvStopView,EvRiderView,EvMetricsView,EvFrame) using the existingMultiHostLayout::fields()registry. When a field is added, removed, or renamed, the test fails and walks the developer through the parity update sequence (wasm DTO → gdext dict → bumpHOST_PROTOCOL_VERSIONif breaking → update the locked list). Catches the silent-drift failure mode without requiring CI access to wasm / gdext crate internals. - Wire-version constant —
elevator_core::HOST_PROTOCOL_VERSIONis the single source of truth. wasm’sABI_VERSIONand gdext’sABI_VERSIONreference it directly at compile time; FFI keeps a literalEV_ABI_VERSION(so cbindgen can resolve it into the generated C header) plus aconst _: () = assert!(...)guard that traps any drift.scripts/check-abi-pins.shverifies both literal and reference shapes. HostBindingpattern — see the close-out section below.
Close-out: documented contract, no Rust trait
Steps 3–5 deliberately exercised the cross-host vocabulary by landing
real, observable changes in three host crates. With those in main,
the trait-vs-pattern question that step 6 deferred had a clear
answer: shared types in elevator-core, hand-written per-host
adapters, no Rust trait. The reasoning:
- The host I/O types are too divergent for a useful trait. FFI returns
EvStatusintegers and*const Tslice pointers. wasm returnstsify-derived discriminated unions overJsValue. gdext returns GodotVariantdictionaries. Bevy emits ECS messages. A trait abstracting these would need associated types for every return shape — at which point each host’simplis a thicker translation layer than the direct hand-written adapter it replaces. - The actual sharing happens at the data layer, not the method layer.
events::log_format::format_event,host_error::ErrorKind, andHOST_PROTOCOL_VERSIONare plain values / enums — every host already references them directly. There is no place atrait HostBinding { fn ev_status(...) }would slot in without re-introducing the per-host divergence we just removed. - The tripwire pattern (step 4) covers the drift risk a trait would have caught.
snapshot_dto_field_names_lockedforces a deliberate sync when a snapshot DTO changes, with the parity-update sequence spelled out in the test’s doc comment. That achieves the trait’s main value (preventing silent drift) without the type gymnastics.
So HostBinding landed as: this document + the shared types in core
- tripwire tests. Adding a Rust trait later is still possible if a concrete need surfaces, but speculatively introducing one would constrain future host evolution without buying real safety.
Supporting Crates
The workspace ships a handful of crates that don’t surface a runtime API to consumers — they exist to keep the host bindings in sync, to drive cross-host code generation, or to validate snapshot determinism. This page is the narrative index for those crates.
Looking for a host crate (Bevy, wasm, FFI, gdext) you can actually depend on? See Using the Bindings. The crates below are about building and validating those hosts, not consuming them.
elevator-tui
A terminal viewer with pause / step / strategy-swap controls, doubling as a headless smoke runner. The TUI never mutates state outside the simulation it owns, so it is also the canonical example of a “read-only host” — its column in bindings.toml shows the inverse pattern from a write-heavy host like GMS.
cargo run -p elevator-tui # interactive viewer
cargo run -p elevator-tui -- --headless # smoke run (CI uses this)
See TUI Debugger for the full key map and panel layout.
elevator-contract
A cross-host snapshot-determinism harness. It runs every scenario in assets/contract-corpus/ against the elevator-core API directly and compares the resulting Simulation::snapshot_checksum() against the golden value committed in assets/contract-corpus/golden.txt. The wasm binding runs the same scenarios through wasm-bindgen in a headless browser via wasm-pack test. Both hosts share golden.txt as the reference; either disagreeing means a host regression.
cargo run -p elevator-contract # core-side run
wasm-pack test --headless --firefox crates/elevator-wasm # wasm-side run
See Snapshots and Determinism for the determinism contract these checksums encode.
elevator-layout-derive
A proc-macro crate exposing #[derive(MultiHostLayout)]. Annotating a #[repr(C)] struct registers its field layout with elevator-layout-runtime so the codegen step can emit matching record types in the host languages. This is the load-bearing piece that lets the C# / GML / harness code stay in sync with the Rust struct without hand-mirroring.
The crate exposes only the macro; it has no runtime. Adding a new exported #[repr(C)] shape means slapping #[derive(MultiHostLayout)] on it and rebuilding — the codegen pickup is automatic.
elevator-layout-runtime
The registry of layout metadata that elevator-layout-derive writes into and elevator-layout-codegen reads from. This crate exists purely to break the dependency cycle: the derive macro and the codegen tool are both compile-time, but the registered metadata has to outlive a single compilation unit. The runtime crate is what makes that possible.
Consumers should treat this crate as an implementation detail; nothing in its API is meant to be called directly.
elevator-layout-codegen
A binary that reads elevator-layout-runtime and emits matching record types to:
crates/elevator-ffi/src/csharp.rs(C# bindings)- the GameMaker GML wrapper scripts
- the
MultiHostLayout-driven harness asserts in the FFI test suite
cargo run -p elevator-layout-codegen
CI runs this in dry-run mode and fails if the output drifts from what’s checked in, so a Rust-side struct edit can’t ship without the host bindings being regenerated in the same PR.
Next steps
- Using the Bindings — host-language integration with a runtime API surface (the page that complements this one).
- Snapshots and Determinism — the determinism contract that
elevator-contractchecksums enforce. - Binding Coverage Manifest — the per-method
bindings.tomlpolicy these crates help enforce.
Performance
This chapter covers complexity, memory, and practical scaling guidance. The core is designed to handle tens of thousands of riders per tick on a single thread, with per-tick cost dominated by the dispatch strategy you choose.
Complexity overview
Let E = elevators, R = riders, S = stops. Per sim.step():
| Phase | Cost | Notes |
|---|---|---|
| Advance transient | O(R) worst-case, O(transitioning riders) typical | Only touches riders in Boarding/Exiting. |
| Dispatch (scoring) | O(E · S) per strategy | Cost-matrix build: one rank call per (car, stop) pair. ETD scales further with aboard riders. |
| Dispatch (assignment) | O(max(E, S)³) | Hungarian / Kuhn-Munkres matching over the cost matrix. |
| Reposition | O(E · S) | Only runs if configured. |
| Movement | O(E) | Pure arithmetic per elevator. |
| Doors | O(E) | Door FSM per elevator. |
| Loading | O(boarding + exiting at each open door) | Uses the rider index for per-stop queues. |
| Metrics | O(events this tick) | Linear in the event count. |
Population queries (residents_at, waiting_at, abandoned_at) are O(1) via the rider index, so UIs and hooks can poll them every tick without penalty.
Memory
Rough per-entity memory (native x86_64, with default components):
| Entity | Bytes (approx.) |
|---|---|
| Stop | ~120 |
| Elevator | ~200 |
Rider (with Route and Patience) | ~160 |
Add your own extension components on top. A 10k-rider simulation with a dozen stops and a handful of elevators fits comfortably under 5 MB of live state.
The event buffer grows until drain_events() is called – see Events and Metrics – Draining events.
Benchmarks
The crate ships a benchmark suite (Criterion) in crates/elevator-core/benches/:
| Bench | Measures |
|---|---|
sim_bench | End-to-end step() across representative scenarios |
scaling_bench | Throughput vs. rider count |
dispatch_bench | Per-strategy cost at a fixed rider load |
multi_line_bench | Multi-group, multi-line buildings |
query_bench | Population and lookup queries |
Run with:
cargo bench -p elevator-core
Results go to target/criterion/ with HTML reports. A nightly GitHub
Actions job (.github/workflows/bench-nightly.yml) reruns the full
suite daily, caches a baseline, and opens an issue when Criterion
flags a significant regression. There is no PR gate – bench noise on
shared runners tends to swamp a strict per-PR check.
Current baselines
Measured on a 32-core Linux x86_64 workstation (Rust stable, release profile, Criterion defaults: 3 s warmup, 5 s measurement). Numbers are the Criterion median unless noted. Shared-runner numbers will be noisier; treat these as orders of magnitude, not tight SLAs.
Primitives
| Item | Time |
|---|---|
tick_movement (single call) | ~1.3 ns |
sim_bench / dispatch / 3e_10s | ~4.0 us |
sim_bench / dispatch / 10e_50s | ~12 us |
Full tick throughput (scaling_bench)
| Scenario | Time per run | Per tick |
|---|---|---|
| 50 elevators, 200 stops, 2 000 riders, 100 ticks | ~14 ms | ~143 us/tick |
| 500 elevators, 5 000 stops, 50 000 riders, 10 ticks | ~520 ms | ~52 ms/tick |
| 10 000-rider spawn pressure test | ~4.9 ms | – |
The realistic row is the one most consumers should care about: a medium office tower with 2 000 concurrent riders runs the full 8-phase tick in well under a millisecond on a single core.
Dispatch strategy comparison (dispatch_bench)
Per step() cost at three scales, holding everything else constant:
| Scale | SCAN | LOOK | NearestCar | ETD |
|---|---|---|---|---|
| 5e, 10s | 61 us | 67 us | 63 us | 66 us |
| 20e, 50s | 436 us | 395 us | 423 us | 413 us |
| 50e, 200s | 2.18 ms | 2.00 ms | 2.04 ms | 1.96 ms |
The strategies in the table 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.
RsrDispatch and DestinationDispatch are also covered by
dispatch_bench but are not yet captured in the numbers
above. Run the bench locally for current values.
Query surface (query_bench)
O(n) over entity population, as the API docs promise:
| Query | 100 | 1 000 | 10 000 |
|---|---|---|---|
query<Rider> | 13 us | 60 us | 744 us |
query_tuple<&Rider, &Patience> | 12 us | 52 us | 859 us |
query_elevators (10/50/200) | 4 us | 5 us | 13 us |
Population queries on RiderIndex (residents_at / waiting_at /
abandoned_at) are O(1) and don’t appear here – they run in tens of
nanoseconds.
Multi-group topology (multi_line_bench)
| Scenario | Time |
|---|---|
multi_3g_2l_5e_20s / step() | ~920 us |
cross_group_routing / 10 groups | ~330 us |
topology_queries / reachable_stops_from | ~177 us |
topology_queries / shortest_route | ~161 us |
dynamic_topology / add_line | ~2.5 us |
dynamic_topology / topology_rebuild | ~21 us |
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 10x the ETD baseline, expect
a 10x slowdown in loaded simulations.
Scaling checklist
For simulations above ~10k concurrent riders or above ~50 elevators:
- Pick the cheapest dispatch strategy that meets your needs.
NearestCarDispatchis usually a better default than ETD at scale. - Split into groups. Each group dispatches independently; two groups of 20 elevators each is cheaper than one group of 40.
- Drain events every tick (or redirect into a bounded ring buffer) to keep memory flat.
- Avoid heavy work in hooks. A hook that iterates all riders every tick is O(R) on top of the dispatch cost – prefer extension-attached flags you can toggle on-event.
- Profile before optimizing. The Criterion benches make it straightforward to identify the hot phase – dispatch dominates far more often than movement or doors.
What we do not provide
- Parallelism. The tick loop is single-threaded by design (determinism > throughput). Run multiple sims in parallel across threads if you need more aggregate work.
- GPU acceleration. Movement and dispatch are scalar – no SIMD or GPU backends.
- Persistent indexes beyond per-stop population. If you need “all riders with extension X”, iterate and filter.
Next steps
- Bevy Integration – a visual wrapper for rapid prototyping and debugging.
- Snapshots and Determinism – save and restore simulation state for replay and testing.
- Writing a Custom Dispatch – build your own strategy and benchmark it against the baselines above.