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 handlers 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.
Handlers 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
IntoHandler 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 handlers │ │ via pipeline │
└──────────────────┘ └──────────────────────┘
Flow
- Build — Register resources into
WorldBuilder. Install plugins (fire-and-forget resource registration) and drivers (returns a handle). - Freeze —
builder.build()produces an immutableWorld. AllResourceIdvalues are dense indices, valid for the lifetime of the World. - 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. - Sequence — Each event gets a monotonic sequence number via
world.next_sequence(). Change detection is causal:changed_atrecords 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 |
| Handler | Box<dyn Handler<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 ;
let mut builder = new;
builder.;
let mut world = builder.build;
let mut handler = tick.into_handler;
handler.run;
assert_eq!;
Driver Model
Drivers are event sources. The Driver trait handles installation;
the returned handle is a concrete type with its own poll() signature.
use ;
// Handle defines its own poll signature — NOT a trait method.
The executor is your main():
let mut wb = new;
wb.install_plugin;
let timer = wb.install_driver;
let io = wb.install_driver;
let mut world = wb.build;
loop
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.
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 = new
.stage // Order → Result<Order, Error>
.and_then // Order → Result<Order, Error>
.catch // Error → () (side effect)
.map // Order → Receipt
.build; // → Pipeline<Order, _> (concrete)
pipeline.run;
Option and Result combinators (.map(), .and_then(), .catch(),
.filter(), .unwrap_or(), etc.) enable typed flow control without
runtime overhead.
Batch pipeline — per-item processing over a buffer
build_batch(capacity) produces a BatchPipeline that owns a
pre-allocated input buffer. Each item flows through the same chain
independently — errors are handled per-item, not per-batch.
let mut batch = new
.stage // Order → Result<Order, Error>
.catch // handle error, continue batch
.map // runs for valid items only
.stage
.build_batch;
// Driver fills input buffer
batch.input_mut.extend_from_slice;
batch.run; // drains buffer, no allocation
No intermediate buffers between stages. The compiler monomorphizes the per-item chain identically to the single-event pipeline.
Change detection
Each resource tracks a changed_at sequence number. Res::is_changed()
and Handler::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:
wb.install_plugin;
Local — per-handler state
Local<T> is state stored inside the handler instance, not in World.
Useful for counters, caches, or any per-handler accumulator.
Callback — context-owning handlers
Callback<C, F, Params> is a handler with per-instance owned context.
Convention: fn handler(ctx: &mut C, params..., event: E).
Virtual / FlatVirtual / FlexVirtual — storage aliases
Type aliases for type-erased handler storage:
use Virtual;
// Heap-allocated (default)
let handler: = Boxnew;
// Behind "smartptr" feature — inline storage via nexus-smartptr
// use nexus_rt::FlatVirtual;
// let handler: FlatVirtual<Event> = flat!(my_handler.into_handler(registry));
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 |
| Handler + Res<T> (read) | 2 | 4 | 5 |
| Handler + ResMut<T> (write) | 3 | 8 | 8 |
| Box<dyn Handler> | 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.
Batch throughput
Total cycles for 100 items through the same pipeline chain.
| Operation | p50 | p99 | p999 |
|---|---|---|---|
| Batch bare (100 items) | 130 | 264 | 534 |
| Linear bare (100 calls) | 196 | 512 | 528 |
| Batch Res<T> (100 items) | 390 | 466 | 612 |
| Linear Res<T> (100 calls) | 406 | 550 | 720 |
Batch dispatch amortizes to ~1.3 cycles/item for compute-heavy chains (~1.5x faster than individual calls).
Construction (cold path)
| Operation | p50 | p99 | p999 |
|---|---|---|---|
| into_handler (1 param) | 21 | 30 | 79 |
| into_handler (4 params) | 45 | 86 | 147 |
| into_handler (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
Limitations
Named functions only
IntoHandler, 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;
// Does NOT work — arity-1 closure with SystemParam
// pipeline.stage(|config: Res<Config>, x: u32| x, registry);
// Works — named function
pipeline.stage;
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 looppipeline— Pipeline composition: bare value, Option, Result with catch, build into Handlerlocal_state— Per-handler state withLocal<T>, independent across handler instancesoptional_resources— Optional dependencies withOption<Res<T>>/Option<ResMut<T>>perf_pipeline— Dispatch latency benchmarks with codegen inspection probesperf_construction— Construction-time latency benchmarks at various aritiesperf_fetch— Fetch dispatch strategy benchmarks
License
See workspace root for license details.