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, Param 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 Param 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.
(Bevy analogy: World, but singletons only — no entities, no components,
no archetypes.)
Res / ResMut — resource 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.
(Bevy analogy: Res<T> and ResMut<T>, same names and semantics.)
Optional resources
Option<Res<T>> and Option<ResMut<T>> resolve to None if the type
was not registered, rather than panicking at build time. Useful for
handlers that can operate with or without a particular resource.
Param — build-time / dispatch-time resolution
The Param trait is the mechanism behind Res<T>, ResMut<T>,
Local<T>, and all other handler parameters. Two-phase resolution:
- Build time —
Param::init(registry)resolves opaque state (e.g. aResourceId) and panics if the required type isn't registered. - Dispatch time —
Param::fetch(world, state)uses the cached state to produce a reference in ~3 cycles.
(Bevy analogy: SystemParam.)
Built-in impls: Res<T>, ResMut<T>, Option<Res<T>>,
Option<ResMut<T>>, Local<T>, RegistryRef, (), and tuples up to
8 params.
Handler / IntoHandler — fn-to-handler conversion
IntoHandler converts a plain fn into a Handler trait object.
Event E is always the last parameter; everything before it is resolved
as Param from a Registry.
(Bevy analogy: IntoSystem / System trait.)
let mut handler = tick.into_handler;
handler.run;
Named functions only — closures do not work with IntoHandler due to
Rust's HRTB inference limitations with GATs (same limitation as Bevy).
Pipeline — pre-resolved processing chains
Typed composition chains where each stage is a named function with
Param 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. Pipeline implements Handler<In>, so it can be
boxed or stored alongside other handlers.
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. Drivers call
world.next_sequence() before each event dispatch to advance the
sequence counter.
// Read side — check if a resource was modified this sequence
// Write side — ResMut stamps changed_at on DerefMut automatically
// Driver side — skip handlers whose inputs haven't changed
if handler.inputs_changed
Plugin — composable registration
Fire-and-forget resource registration units. Consumed by WorldBuilder.
(Bevy analogy: Plugin.)
wb.install_plugin;
Local — per-handler state
Local<T> is state stored inside the handler instance, not in World.
Initialized with Default::default() at handler creation time. Each
handler instance gets its own independent copy — two handlers created
from the same function have separate Local values.
(Bevy analogy: Local<T>.)
let mut handler_a = count_events.into_handler;
let mut handler_b = count_events.into_handler;
handler_a.run; // handler_a local=1
handler_b.run; // handler_b local=1 (independent)
handler_a.run; // handler_a local=2
Callback — context-owning handlers
Callback<C, F, Params> is a handler with per-instance owned context.
Use it when each handler instance needs private state that isn't shared
via World — per-timer metadata, per-connection codec state, protocol
state machines.
Convention: fn handler(ctx: &mut C, params..., event: E) — context
first, Param-resolved resources in the middle, event last.
let mut cb = on_timeout.into_callback;
cb.run;
// Context is pub — accessible outside dispatch
assert_eq!;
RegistryRef — runtime handler creation
RegistryRef is a Param that provides read-only access to the
Registry during handler dispatch. Enables handlers to create new
handlers at runtime via IntoHandler::into_handler or
IntoCallback::into_callback.
Installer — event source installation
Installer is the install-time trait for event sources. The installer
registers its resources into WorldBuilder and returns a concrete
poller whose poll() method drives the event lifecycle. See the
Driver Model section for the full pattern.
Timer driver (feature: timer)
Integrates nexus_timer::Wheel as a driver. TimerInstaller registers
the wheel into WorldBuilder and returns a TimerPoller.
TimerPoller::poll(world, now)drains expired timers and fires handlers- Handlers reschedule themselves via
ResMut<TimerWheel<S>> Periodichelper for recurring timers- Inline storage variants behind
smartptrfeature:InlineTimerWheel,FlexTimerWheel
Mio driver (feature: mio)
Integrates mio as an IO driver. MioInstaller registers the
MioDriver (wrapping mio::Poll + handler slab) and returns a
MioPoller.
MioPoller::poll(world, timeout)polls for readiness and fires handlers- Move-out-fire pattern: handler is removed from slab, fired, and must re-insert itself to receive more events
- Stale tokens (already removed) are silently skipped
- Inline storage variants behind
smartptrfeature:InlineMio,FlexMio
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));
Virtual<E> for heap-allocated. FlatVirtual<E> for fixed inline
(panics if handler doesn't fit). FlexVirtual<E> for inline with
heap fallback.
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 Param) do accept closures:
// Works — arity-0 closure
pipeline.stage;
// Does NOT work — arity-1 closure with Param
// 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.