Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Extensions and Hooks

The core library is deliberately unopinionated – it provides riders, elevators, and stops, but your game decides what a rider means. Maybe riders have a VIP status, a mood, a destination preference, or a cargo manifest. Extensions and hooks are how you layer game-specific logic on top of the simulation without forking or wrapping.

Extension components

Extension components let you attach arbitrary typed data to any entity. They work like the built-in components (Rider, Elevator, etc.) but are defined by your code.

Step 1: Define your type

Extension types must implement Serialize and DeserializeOwned (for snapshot support), plus Send + Sync:

#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
struct VipTag {
    level: u32,
    lounge_access: bool,
}
}

Step 2: Register with the builder

Call .with_ext::<T>("name") on the builder to register the extension type. The name string is used for snapshot serialization:

use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct VipTag { level: u32, lounge_access: bool }
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;

fn main() -> Result<(), SimError> {
    let mut sim = SimulationBuilder::new()
        .stop(StopId(0), "Ground", 0.0)
        .stop(StopId(1), "Top", 10.0)
        .elevator(ElevatorConfig::default())
        .with_ext::<VipTag>("vip_tag")
        .build()?;
    Ok(())
}

Step 3: Attach to entities

Use world.insert_ext() to attach your component to an entity:

use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct VipTag { level: u32, lounge_access: bool }
use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
fn main() -> Result<(), SimError> {
let mut sim = SimulationBuilder::new()
    .stop(StopId(0), "Ground", 0.0)
    .stop(StopId(1), "Top", 10.0)
    .elevator(ElevatorConfig::default())
    .with_ext::<VipTag>("vip_tag")
    .build()?;
let rider_id = sim.spawn_rider_by_stop_id(StopId(0), StopId(1), 75.0)?;

sim.world_mut().insert_ext(
    rider_id,
    VipTag { level: 3, lounge_access: true },
    "vip_tag",
);
Ok(())
}

Step 4: Read it back

Use world.get_ext() for a cloned value, or world.get_ext_mut() for a mutable reference:

#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct VipTag { level: u32, lounge_access: bool }
use elevator_core::prelude::*;
fn run(sim: &mut Simulation, rider_id: EntityId) {
// Read (cloned)
if let Some(vip) = sim.world().get_ext::<VipTag>(rider_id) {
    println!("VIP level: {}", vip.level);
}

// Mutate
if let Some(vip) = sim.world_mut().get_ext_mut::<VipTag>(rider_id) {
    vip.level += 1;
}
}
}

Extensions are automatically cleaned up when an entity is despawned, and they participate in snapshot save/load as long as you register them with .with_ext() or world.register_ext().

World resources

For global data that is not attached to a specific entity, use resources. Resources are typed singletons stored on the World:

#![allow(unused)]
fn main() {
use elevator_core::prelude::*;
fn run(sim: &mut Simulation) {
// Insert a resource
sim.world_mut().insert_resource(42u32);

// Read it
if let Some(value) = sim.world().resource::<u32>() {
    println!("The answer is {}", value);
}

// Mutate it
if let Some(value) = sim.world_mut().resource_mut::<u32>() {
    *value += 1;
}
}
}

Resources are useful for game state that hooks need to read or write – score counters, time-of-day multipliers, spawn rate controllers, and so on.

Extension vs. resource: which do I want?

NeedUse
Per-entity data that varies by rider/elevator (VIP status, mood, cargo manifest)Extension (with_ext + insert_ext)
One-of value for the whole sim (score, difficulty, tick clock mirror)Resource (insert_resource)
Data that must survive snapshot save/loadExtension (register by name; resources are not snapshotted)
Quick scratchpad you can wipe between ticksResource
Query “all entities that have X”Extension (iterate world.rider_ids() and filter on get_ext::<T>)

Extensions are auto-cleaned on despawn_rider; resources persist until you remove them.

Lifecycle hooks

Hooks let you inject custom logic before or after any of the seven tick phases. They receive &mut World, so they can read and modify any entity or resource.

Registering hooks on the builder

use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;

fn main() -> Result<(), SimError> {
    let sim = SimulationBuilder::new()
        .stop(StopId(0), "Ground", 0.0)
        .stop(StopId(1), "Top", 10.0)
        .elevator(ElevatorConfig::default())
        .after(Phase::Loading, |world| {
            // This runs after every Loading phase.
            // Check for newly arrived riders, update scores, etc.
        })
        .before(Phase::Dispatch, |world| {
            // This runs before every Dispatch phase.
            // Adjust demand, spawn dynamic riders, etc.
        })
        .build()?;
    Ok(())
}

Registering hooks after build

You can also add hooks to a running simulation:

use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
fn main() -> Result<(), SimError> {
let mut sim = SimulationBuilder::new()
    .stop(StopId(0), "Ground", 0.0)
    .stop(StopId(1), "Top", 10.0)
    .elevator(ElevatorConfig::default())
    .build()?;
sim.add_after_hook(Phase::Loading, |world| {
    // Post-loading logic
});
Ok(())
}

Group-specific hooks

For multi-group buildings, you can register hooks that only fire for a specific elevator group:

use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
fn main() -> Result<(), SimError> {
let sim = SimulationBuilder::new()
    .stop(StopId(0), "Ground", 0.0)
    .stop(StopId(1), "Top", 10.0)
    .elevator(ElevatorConfig::default())
    .after_group(Phase::Loading, GroupId(0), |world| {
        // Only runs after loading for group 0
    })
    .build()?;
Ok(())
}

What hooks can (and can’t) do

Hooks receive &mut World — not &mut Simulation. This means:

  • You can: read/mutate any component, insert/remove extensions, add/remove resources, read tick state via a resource mirror (see example below).
  • You cannot: call sim.step(), sim.spawn_rider_by_stop_id(), sim.snapshot() or other Simulation-level methods while a tick is in flight — the simulation is borrowed.
  • Spawning during a hook: use world.spawn() + direct component inserts, or queue SpawnRequests into a resource and drain them after sim.step() returns. The before(Phase::Dispatch, ...) slot is a convenient place to inject riders because the next phase will see them.
  • Events: hooks do not emit events directly. Use an extension/resource to record side effects and translate to events in game code.

Hook execution order within a tick:

before(AdvanceTransient) -> [phase] -> after(AdvanceTransient)
before(Dispatch)         -> [phase] -> after(Dispatch)
  ... and so on for each phase

Group-specific hooks run alongside global hooks for the same phase; all before hooks fire before the phase body, all after hooks fire after.

Available phases

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

Combining extensions and hooks

The real power comes from using extensions and hooks together. Here is a walkthrough: we will track how long each rider has been waiting and print a warning if they wait too long.

The hook closure cannot call sim.current_tick() directly (the simulation is borrowed during the tick), so we store the tick in a World resource and update it each iteration. The CurrentTick helper struct is defined at the bottom of the listing.

use elevator_core::prelude::*;
use elevator_core::config::ElevatorConfig;
use elevator_core::stop::StopId;
use serde::{Serialize, Deserialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
struct WaitWarning {
    warned: bool,
}

fn main() -> Result<(), SimError> {
    let mut sim = SimulationBuilder::new()
        .stop(StopId(0), "Lobby", 0.0)
        .stop(StopId(1), "Floor 2", 4.0)
        .stop(StopId(2), "Floor 3", 8.0)
        .elevator(ElevatorConfig { starting_stop: StopId(0), ..Default::default() })
        .with_ext::<WaitWarning>("wait_warning")
        .after(Phase::Metrics, |world| {
            // Check all waiting riders for long waits.
            let rider_ids: Vec<EntityId> = world.rider_ids();
            for rid in rider_ids {
                let Some(rider) = world.rider(rid) else { continue };
                if rider.phase() != RiderPhase::Waiting { continue; }

                let current_tick = world.resource::<CurrentTick>()
                    .map_or(0, |t| t.0);
                let wait = current_tick.saturating_sub(rider.spawn_tick());

                if wait > 300 {
                    if let Some(warning) = world.get_ext_mut::<WaitWarning>(rid) {
                        if !warning.warned {
                            warning.warned = true;
                            println!("WARNING: rider {:?} has been waiting {} ticks!", rid, wait);
                        }
                    }
                }
            }
        })
        .build()?;

    // Seed the resource the hook reads.
    sim.world_mut().insert_resource(CurrentTick(0));

    // Spawn some riders and attach extensions.
    let r1 = sim.spawn_rider_by_stop_id(StopId(0), StopId(2), 75.0)?;
    sim.world_mut().insert_ext(r1, WaitWarning { warned: false }, "wait_warning");

    for _ in 0..600 {
        // Update the current tick resource before stepping.
        if let Some(t) = sim.world_mut().resource_mut::<CurrentTick>() {
            t.0 = sim.current_tick();
        }
        sim.step();
    }

    Ok(())
}

struct CurrentTick(u64);

This pattern – define a component, register it, attach it on spawn, read/write it in a hook – is the standard way to add game-specific behavior to the simulation.

Next steps

Head to Configuration to learn about RON config files and the programmatic configuration API.