Working title: the crate ships as
esc(useesc = ...inCargo.toml,use esc::prelude::*;in code). The name is provisional. -- misspelled & won't fix, as I am used to it now
In a traditional ECS, a system both reads and writes the world directly; later systems observe the mutations of earlier ones, and the framework's job is to arbitrate that ordering. Here, the core is different: a tick is a discrete transformation, and systems describe state changes against the current state rather than performing them. Every system in a phase observes the same starting world; nothing mutates until every system has finished proposing. The schedule's job is to:
- Run every system against the current world.
- Bucket all proposed
Set/Removechanges by(component type, entity). - Hand each bucket to its registered reducer; the reducer decides the next value for that pair.
- Apply the resulting mutations — despawn first, then component reductions, then spawn — in a single serial pass.
This framework is heavily inspired by bevy and entt and I recommend using them instead if time-stepped simulations is not what you need.
Components are plain Rust types. Any Send + Sync + 'static type is a component via a blanket impl — there is no derive macro and no trait to implement. Component types must, however, be registered on the world before they are used.
use esc::prelude::*;
struct Position { x: i32, y: i32 }
struct Velocity { x: i32, y: i32 }The World owns every entity, the components attached to them, and world-level Resources.
use esc::prelude::*;
let mut world = World::new();Component types must be registered on a world before they are used. Registration assigns each type a dense id and creates its storage column; it is done up front, and the set of component types is fixed for the world's life.
use esc::prelude::*;
struct Position { x: i32, y: i32 }
struct Velocity { x: i32, y: i32 }
// Register directly…
let mut world = World::new();
world.register::<Position>();
world.register::<Velocity>();
// …or with the builder form:
let world = World::new()
.with_component::<Position>()
.with_component::<Velocity>();Reading, writing, querying, or tracking a type that was never registered panics — an unregistered type is assumed not to exist in the simulation, so touching it is a programming error, not a silent "absent". A world holds at most 64 component types.
Entities are opaque ids returned by World::spawn. Components are attached at spawn time via a ComponentBundle — every component in the bundle must already be registered.
use esc::prelude::*;
struct Position { x: i32, y: i32 }
struct Velocity { x: i32, y: i32 }
let mut world = World::new()
.with_component::<Position>()
.with_component::<Velocity>();
let entity = world.spawn(
ComponentBundle::new()
.with(Position { x: 0, y: 0 })
.with(Velocity { x: 1, y: 0 }),
);
assert!(world.contains::<Position>(entity));
let pos = world.get::<Position>(entity).unwrap();
assert_eq!((pos.x, pos.y), (0, 0));Resources are world-level singletons keyed by type, used for ambient state that does not belong to any entity. Unlike components, resources need no registration.
use esc::prelude::*;
struct Time { seconds: f32 }
let mut world = World::new();
world.insert_resource(Time { seconds: 0.0 });
let time = world.resource::<Time>().unwrap();
assert_eq!(time.seconds, 0.0);Systems read resources through &World. To mutate a resource from a system, see Mutable Systems.
Systems are plain functions or closures with the signature Fn(&World) -> Proposals. Any matching function is a system via a blanket impl.
use esc::prelude::*;
struct Position { x: i32, y: i32 }
struct Velocity { x: i32, y: i32 }
fn movement(world: &World) -> Proposals {
world
.query()
.with::<Position>()
.with::<Velocity>()
.into_iter()
.map(|view| {
let p = view.get::<Position>().unwrap();
let v = view.get::<Velocity>().unwrap();
Proposal::set(view.id(), Position { x: p.x + v.x, y: p.y + v.y })
})
.collect()
}A system never calls world.insert or world.despawn. It builds Proposals and returns them; the schedule does the writing.
A Proposal is one of: set a component, remove a component, spawn a new entity, despawn an existing entity.
use esc::prelude::*;
struct Tile;
let mut world = World::new();
let entity_id = world.spawn(ComponentBundle::new());
let _ = Proposal::set(entity_id, Tile);
let _ = Proposal::remove::<Tile>(entity_id);
let _ = Proposal::spawn(ComponentBundle::new().with(Tile));
let _ = Proposal::despawn(entity_id);Proposals are returned in a Proposals bag — an unordered multiset newtype around Vec<Proposal>. The framework deliberately does not commit to any iteration order between systems' contributions; reducers must be commutative.
A reducer collapses every proposed change to one (component, entity) pair into the next value. The signature is Fn(Option<&C>, Changes<C>) -> Option<C> — returning None removes the component, Some(v) sets it.
use esc::prelude::*;
#[derive(Clone, Copy)]
struct Score(i32);
fn sum_scores(current: Option<&Score>, changes: Changes<Score>) -> Option<Score> {
let start = current.copied().unwrap_or(Score(0)).0;
let total = changes.into_iter().fold(start, |acc, ch| match ch {
ComponentChange::Set(s) => acc + s.0,
ComponentChange::Remove => acc,
});
Some(Score(total))
}If two systems both propose a Set for the same (component, entity), both contributions land in the reducer's Changes<C> argument. The reducer decides what "merging two writes" means for that component type.
A Phase is a registered set of systems, reducers, and policies that share one full proposal/reduce/apply cycle. Phases are the unit of ordering: phases within a schedule run sequentially, and phase B sees the fully-mutated world produced by phase A.
use esc::prelude::*;
struct Tile;
fn no_op(_w: &World) -> Proposals { Proposals::new() }
let phase = Phase::new("tick")
.with_default_strategy(DefaultStrategy::LastWriteWins)
.with_system(no_op);A Schedule is an ordered list of phases. Schedule::step runs each phase's full cycle in turn against the world.
use esc::prelude::*;
fn no_op(_w: &World) -> Proposals { Proposals::new() }
let mut world = World::new();
let schedule = Schedule::new().with_phase(
Phase::new("tick")
.with_default_strategy(DefaultStrategy::LastWriteWins)
.with_system(no_op),
);
schedule.step(&mut world);Entities with Position and Velocity, a movement system, and a reducer that sums per-axis deltas:
use esc::prelude::*;
#[derive(Clone, Copy, Default)]
struct Position { x: i32, y: i32 }
#[derive(Clone, Copy)]
struct Velocity { x: i32, y: i32 }
fn movement(world: &World) -> Proposals {
world
.query()
.with::<Position>()
.with::<Velocity>()
.into_iter()
.map(|view| {
let v = view.get::<Velocity>().unwrap();
Proposal::set(view.id(), Position { x: v.x, y: v.y })
})
.collect()
}
fn sum_positions(current: Option<&Position>, changes: Changes<Position>) -> Option<Position> {
let mut next = current.copied().unwrap_or_default();
for ch in changes {
if let ComponentChange::Set(delta) = ch {
next.x += delta.x;
next.y += delta.y;
}
}
Some(next)
}
fn main() {
let mut world = World::new()
.with_component::<Position>()
.with_component::<Velocity>();
let entity = world.spawn(
ComponentBundle::new()
.with(Position { x: 0, y: 0 })
.with(Velocity { x: 1, y: 0 }),
);
let schedule = Schedule::new().with_phase(
Phase::new("tick")
.with_reducer(sum_positions)
.with_system(movement),
);
for _ in 0..3 {
schedule.step(&mut world);
}
let p = world.get::<Position>(entity).unwrap();
assert_eq!((p.x, p.y), (3, 0));
}For a more complete example see tests/it-conway-game-of-life.rs, which implements Conway's Game of Life on a periodic grid.
Query narrows the entity set by component membership; with keeps entities that have a component, without keeps entities that lack one.
use esc::prelude::*;
struct Player;
struct Position;
struct Alive;
fn select_living_players(world: &World) -> Proposals {
for view in world.query().with::<Player>().with::<Position>().without::<Alive>() {
let _id = view.id();
}
Proposals::new()
}A query iterates EntityViews; each view exposes the entity id plus typed component access via view.get::<C>().
A Phase without a registered reducer for some component type panics when a proposal targets that type — unless a DefaultStrategy is set. The framework ships one strategy today: LastWriteWins.
use esc::prelude::*;
struct Health(i32);
fn heal(world: &World) -> Proposals {
world
.entities()
.map(|e| Proposal::set(e, Health(100)))
.collect()
}
let phase = Phase::new("tick")
.with_default_strategy(DefaultStrategy::LastWriteWins)
.with_system(heal);Per-type reducers always take precedence over the default strategy.
Systems are read-only by default. A mutable system is a system that declares one to four resource types it needs to mutate; the framework hands it &mut R references alongside &World.
use esc::prelude::*;
struct Counter(i32);
fn bump(_world: &World, c: &mut Counter) -> Proposals {
c.0 += 1;
Proposals::new()
}
let mut world = World::new();
world.insert_resource(Counter(0));
Schedule::new()
.with_phase(Phase::new("tick").with_mut_system(bump))
.step(&mut world);Constraints:
- At most one mutable system per phase may declare any given resource. Two mut systems competing for
&mut Counterin the same phase is a registration-time panic. - While a mutable system is running, any other system in the same phase that calls
world.resource::<C>()for a declaredCpanics with a clear message naming the phase and the conflicting resource. - Resources are restored to the world even if the system panics; the lock is released on unwind.
A read-only system can be gated on a predicate via SystemExt::when. The wrapped system runs its body only when the predicate returns true; otherwise it contributes an empty Proposals to the phase.
use esc::prelude::*;
struct Paused;
struct Enemy;
fn physics(_w: &World) -> Proposals { Proposals::new() }
fn rendering_hints(_w: &World) -> Proposals { Proposals::new() }
fn spawn_more_enemies(_w: &World) -> Proposals { Proposals::new() }
let _ = Phase::new("tick")
.with_default_strategy(DefaultStrategy::LastWriteWins)
.with_system(physics)
.with_system(rendering_hints.when(|w| w.resource::<Paused>().is_none()))
.with_system(
spawn_more_enemies.when(|w| w.query().with::<Enemy>().into_iter().count() < 10),
);Predicates compose: system.when(p1).when(p2) runs the body only if both pass. The outer predicate is checked first.
Predicates have no persistent state — they are Fn(&World) -> bool reads of the current world. For stateful gates (every N ticks, throttle, run-once), put the counter in a Resource advanced by a mutable system, then read it from the predicate.
A phase can opt into concurrent system execution and concurrent reducer fan-out. Both are off by default and compose orthogonally.
use esc::prelude::*;
fn no_op(_w: &World) -> Proposals { Proposals::new() }
let _ = Phase::new("tick")
.with_default_strategy(DefaultStrategy::LastWriteWins)
.with_system(no_op)
.parallel() // fan systems out across threads
.parallel_reduce(); // fan reducer groups out across threadsUnder .parallel(), all systems in the phase — mut and read-only — run concurrently on rayon's work-stealing pool. Mutable resources are taken out of the world before fan-out and put back after join, including on panic. The framework gives no order guarantee between systems; reducers must be commutative.
Under .parallel_reduce(), each (component, entity) reduction group runs on its own task. Useful when reducers are heavy or the world has many entities.
Track what entered, changed, or left the world each tick. Opt in per component type (the type must be registered first):
use esc::prelude::*;
struct Position;
let world = World::new()
.with_component::<Position>()
.with_tracked::<Position>();Once a type is tracked, queries and the world expose two visibility windows:
use esc::prelude::*;
struct Position;
struct Microbe;
let world = World::new()
.with_component::<Position>()
.with_component::<Microbe>()
.with_tracked::<Position>()
.with_tracked::<Microbe>();
// Previous tick (the common case — what changed last tick):
for view in world.query().with::<Microbe>().changed::<Position>() {
let _ = view.id();
}
for view in world.query().added::<Position>() {
let _ = view.id();
}
for entity in world.removed::<Position>() {
let _ = entity;
}
for entity in world.despawned() {
let _ = entity;
}
for entity in world.spawned() {
let _ = entity;
}
// This tick so far (same-tick reactivity — what's already happened this Schedule::step):
for view in world.query().with::<Microbe>().pending().changed::<Position>() {
let _ = view.id();
}
for entity in world.pending().removed::<Position>() {
let _ = entity;
}Semantics:
addedandchangedare disjoint: an entity that just gotCthis tick appears inadded, not inchanged. Iterate both to cover all upserts.- The previous-tick window is published at the start of every
Schedule::step; direct callers ofphase.step(&mut world)manage the boundary viaWorld::flush_events. - The two windows are disjoint by construction —
pending()exposes the in-progress buffer, the bare reads expose the published one. - Direct out-of-
Phase::stepmutations on the world (e.g.world.spawn(...)between phases or during setup) are silent. Events describe the proposal pipeline's output, not arbitrary world mutations.
This is enough to maintain a derived view of the world (spatial indices, per-faction counts, ordered tables) with cost proportional to the change in that view, not its size.
The public API is exported from the crate root and from esc::prelude::*. Module paths under src/ are private; users should not import esc::world::World directly.
use esc::prelude::*;For more, the integration tests under tests/ are worked examples, and the design history lives under docs/.

