nexus-rt 0.1.0

Single-threaded, event-driven runtime primitives with pre-resolved dispatch
Documentation

nexus-rt

Single-threaded, event-driven runtime primitives with pre-resolved dispatch.

nexus-rt provides the building blocks for constructing runtimes where user code runs as systems dispatched over shared state. It is not an async runtime — there is no task scheduler, no work stealing, no Future polling. Your main() is the executor.

Design

nexus-rt is heavily inspired by Bevy ECS. Systems as plain functions, SystemParam for declarative dependency injection, Res<T> / ResMut<T> wrappers, change detection via sequence stamps, the Plugin trait for composable registration — these are Bevy's ideas, and in many cases the implementation follows Bevy's patterns closely (including the HRTB double-bound trick that makes IntoSystem work). Credit where it's due: Bevy's system model is an excellent piece of API design.

Where nexus-rt diverges is the target workload. Bevy is built for simulation: many entities mutated per frame, parallel schedules, component queries over archetypes. nexus-rt is built for event-driven systems: singleton resources, sequential dispatch, and monotonic sequence numbers instead of frame ticks. There are no entities, no components, no archetypes — just a typed resource store where each event advances a sequence counter and causality is tracked per-resource.

The result is a much smaller surface area tuned for low-latency event processing rather than game-world state management.

Architecture

                        Build Time                          Dispatch Time
                   ┌──────────────────┐               ┌──────────────────────┐
                   │                  │               │                      │
                   │  WorldBuilder    │               │       World          │
                   │                  │               │                      │
                   │  ┌────────────┐  │    build()    │  ┌────────────────┐  │
                   │  │ Registry   │──┼──────────────►│  │ ResourceSlot[] │  │
                   │  │ TypeId→Idx │  │               │  │ ptr+changed_at │  │
                   │  └────────────┘  │               │  └───────┬────────┘  │
                   │                  │               │          │           │
                   │  install_plugin  │               │    get(id) ~3 cyc   │
                   │  install_driver  │               │          │           │
                   └──────────────────┘               └──────────┼───────────┘
                          │                                      │
                          │ returns Handle                       │
                          ▼                                      ▼
                   ┌──────────────────┐               ┌──────────────────────┐
                   │  Driver Handle   │               │  poll(&mut World)    │
                   │                  │               │                      │
                   │  Pre-resolved    │──────────────►│  1. next_sequence()  │
                   │  ResourceIds     │               │  2. get resources    │
                   │                  │               │  3. poll IO source   │
                   │  Owns pipeline   │               │  4. dispatch events  │
                   │  or systems      │               │     via pipeline     │
                   └──────────────────┘               └──────────────────────┘

Flow

  1. Build — Register resources into WorldBuilder. Install plugins (fire-and-forget resource registration) and drivers (returns a handle).
  2. Freezebuilder.build() produces an immutable World. All ResourceId values are dense indices, valid for the lifetime of the World.
  3. Poll loop — Your code calls driver.poll(&mut world) in a loop. Each driver owns its event lifecycle internally: poll IO, decode events, dispatch through its pipeline, mutate world state.
  4. Sequence — Each event gets a monotonic sequence number via world.next_sequence(). Change detection is causal: changed_at records which event caused the mutation.

Dispatch tiers

Tier Purpose Overhead
Pipeline Pre-resolved stage chains inside drivers. The workhorse. ~2 cycles p50
Callback Dynamic per-instance context + pre-resolved params. ~2 cycles p50
System Box<dyn System<E>> for type-erased dispatch. ~2 cycles p50

All tiers resolve SystemParam state at build time. Dispatch-time cost is a bounds-checked index into a Vec — no hashing, no searching.

Quick Start

use nexus_rt::{WorldBuilder, ResMut, IntoSystem, System};

let mut builder = WorldBuilder::new();
builder.register::<u64>(0);
let mut world = builder.build();

fn tick(mut counter: ResMut<u64>, event: u32) {
    *counter += event as u64;
}

let mut system = tick.into_system(world.registry_mut());

system.run(&mut world, 10u32);

assert_eq!(*world.resource::<u64>(), 10);

Driver Model

Drivers are event sources. The Driver trait handles installation; the returned handle is a concrete type with its own poll() signature.

use nexus_rt::{Driver, WorldBuilder, World, ResourceId};

struct TimerInstaller { resolution_ms: u64 }
struct TimerHandle { timers_id: ResourceId }

impl Driver for TimerInstaller {
    type Handle = TimerHandle;

    fn install(self, world: &mut WorldBuilder) -> TimerHandle {
        world.register(Vec::<u64>::new());
        // ... register other resources ...
        let timers_id = world.registry().id::<Vec<u64>>();
        TimerHandle { timers_id }
    }
}

// Handle defines its own poll signature — NOT a trait method.
impl TimerHandle {
    fn poll(&mut self, world: &mut World, now_ms: u64) {
        // get resources via pre-resolved IDs, fire expired timers
    }
}

The executor is your main():

let mut wb = WorldBuilder::new();
wb.install_plugin(TradingPlugin { /* config */ });
let timer = wb.install_driver(TimerInstaller { resolution_ms: 100 });
let io = wb.install_driver(IoInstaller::new());
let mut world = wb.build();

loop {
    let now = std::time::Instant::now();
    timer.poll(&mut world, now);
    io.poll(&mut world);
}

Features

World — typed singleton store

Type-erased resource storage with dense ResourceId indexing. ~3 cycles per dispatch-time access. Frozen after build — no inserts, no removes.

Res / ResMut — system parameters

Declare resource dependencies in function signatures. Res<T> for shared reads, ResMut<T> for exclusive writes. ResMut stamps changed_at on DerefMut — the act of writing is the change signal.

fn process(config: Res<Config>, mut state: ResMut<State>, event: Event) {
    // config is read-only, state is read-write
}

Pipeline — pre-resolved processing chains

Typed composition chains where each stage is a named function with SystemParam dependencies resolved at build time.

let mut pipeline = PipelineStart::<Order>::new()
    .stage(validate, registry)       // Order → Result<Order, Error>
    .and_then(enrich, registry)      // Order → Result<Order, Error>
    .catch(log_error, registry)      // Error → () (side effect)
    .map(submit, registry)           // Order → Receipt
    .build();                        // → Pipeline<Order, _> (concrete)

pipeline.run(&mut world, order);

Option and Result combinators (.map(), .and_then(), .catch(), .filter(), .unwrap_or(), etc.) enable typed flow control without runtime overhead.

Events — buffer types

Events<T>, EventWriter<T>, EventReader<T> integrate as system parameters. EventReader supports both iter() (peek) and drain() (consume).

Change detection

Each resource tracks a changed_at sequence number. Res::is_changed() and System::inputs_changed() compare against the world's current sequence. Drivers call world.next_sequence() before each event dispatch.

Plugin — composable registration

Fire-and-forget resource registration units. Consumed by WorldBuilder:

struct MyPlugin { /* config */ }

impl Plugin for MyPlugin {
    fn build(self, world: &mut WorldBuilder) {
        world.register(MyState::new());
        world.register_default::<Events<MyEvent>>();
    }
}

wb.install_plugin(MyPlugin { /* ... */ });

Local — per-system state

Local<T> is state stored inside the system instance, not in World. Useful for counters, caches, or any per-system accumulator.

Callback — context-owning systems

Callback<C, F, Params> is a system with per-instance owned context. Convention: fn handler(ctx: &mut C, params..., event: E).

Performance

All measurements in CPU cycles, pinned to a single core with turbo boost disabled.

Dispatch (hot path)

Operation p50 p99 p999
Baseline hand-written fn 2 3 4
3-stage pipeline (bare) 2 2 4
3-stage pipeline (Res<T>) 2 3 5
System + Res<T> (read) 2 4 5
System + ResMut<T> (write) 3 8 8
Box<dyn System> 2 9 9
inputs_changed (1 param) 1 1 2
inputs_changed (8 params) 4 6 9

Pipeline dispatch matches hand-written code — zero-cost abstraction confirmed.

Construction (cold path)

Operation p50 p99 p999
into_system (1 param) 21 30 79
into_system (4 params) 45 86 147
into_system (8 params) 93 156 221
.stage() (2 params) 28 48 96

Construction cost is paid once at build time, never on the dispatch hot path.

Running benchmarks

taskset -c 0 cargo run --release -p nexus-rt --example perf_pipeline
taskset -c 0 cargo run --release -p nexus-rt --example perf_construction

Limitations

Named functions only

IntoSystem, IntoCallback, and IntoStage (arity 1+) require named fn items — closures do not work due to Rust's HRTB inference limitations with GATs. This is the same limitation as Bevy's system registration.

Arity-0 pipeline stages (no SystemParam) do accept closures:

// Works — arity-0 closure
pipeline.stage(|x: u32| x * 2, registry);

// Does NOT work — arity-1 closure with SystemParam
// pipeline.stage(|config: Res<Config>, x: u32| x, registry);

// Works — named function
fn transform(config: Res<Config>, x: u32) -> u32 { x + *config as u32 }
pipeline.stage(transform, registry);

Single-threaded

World is !Sync by design. All dispatch is single-threaded, sequential. This is intentional — for latency-sensitive event processing, eliminating coordination overhead matters more than parallelism.

Frozen after build

No resources can be added or removed after WorldBuilder::build(). All registration happens at build time. This enables dense indexing and eliminates runtime bookkeeping.

Examples

  • mock_runtime — Complete driver model: plugin registration, driver installation, explicit poll loop
  • pipeline — Pipeline composition: bare value, Option, Result with catch, build into System
  • events — Event buffers: peek with iter(), consume with drain()
  • local_state — Per-system state with Local<T>, independent across system instances
  • optional_resources — Optional dependencies with Option<Res<T>> / Option<ResMut<T>>
  • perf_pipeline — Dispatch latency benchmarks with codegen inspection probes
  • perf_construction — Construction-time latency benchmarks at various arities
  • perf_fetch — Fetch dispatch strategy benchmarks

License

See workspace root for license details.