Expand description
§pragma_ecs
An Entity Component System in pure Rust for game engines and frameworks built on
pragma_core. It owns no threads: systems run as tasks of the
pragma_core frame graph on the engine’s thread pool, and conflicting data access between
systems is a build error of the schedule, not a data race in a frame.
| Concept | Items |
|---|---|
| Entities and components | World, Entity (generational ids), Component (any Send + Sync + 'static type), Bundle (a tuple of components spawned or inserted at once) |
| Resources (singletons) | World::insert_resource, Resource, Res, ResMut |
| Queries | Query over QueryData (&T, &mut T as Mut<T>, Option<&T>, Entity, tuples) and QueryFilter (With, Without, Added, Changed); declared Access |
| Systems | plain functions of View, Res, ResMut, Commands, EventWriter, EventReader; a Schedule built by a ScheduleBuilder |
| Deferred changes | Commands, EntityCommands, Events, EventCursor, applied at the sync point at the end of Schedule::run |
| Errors | EcsError (#[non_exhaustive]) |
Components live in archetype tables: entities with the same component set share a table, one
column per type, so a query iterates dense slices. A Schedule runs systems with disjoint
access in parallel, orders conflicting ones as told (or fails to build), and applies the
commands and events systems deferred at the end of every frame in a fixed order, so the world
after a frame is the same on any number of workers. The design is in
docs/architecture.md,
the reasons in
DECISIONS.md.
use pragma_ecs::pragma_core::prelude::{PoolConfig, ThreadPool};
use pragma_ecs::{EcsError, Res, Schedule, View, World};
struct Position(f32, f32);
struct Velocity(f32, f32);
struct Dt(f32);
fn movement(mut bodies: View<(&mut Position, &Velocity)>, dt: Res<Dt>) -> Result<(), EcsError> {
for (mut position, velocity) in bodies.iter_mut()? {
position.0 += velocity.0 * dt.0;
position.1 += velocity.1 * dt.0;
}
Ok(())
}
let mut world = World::new();
let ball = world.spawn_bundle((Position(0.0, 0.0), Velocity(1.0, 2.0)))?;
world.insert_resource(Dt(0.5));
let mut builder = Schedule::builder();
builder.add_system("movement", movement)?;
let mut schedule = builder.build()?;
let pool = ThreadPool::new(PoolConfig::default().with_workers(2))?;
schedule.run(&mut world, &pool)?;
assert_eq!(world.get::<Position>(ball).map(|p| (p.0, p.1)), Some((0.5, 1.0)));Every operation that grows storage reserves memory with try_reserve and reports a failure
as EcsError::OutOfMemory instead of aborting; the library contains no unsafe code.
§Semver guarantees
The public API follows semver, except for items hidden from the documentation
(#[doc(hidden)]): the machinery types that the sealed traits (QueryData,
QueryFilter, SystemParam, SystemOutput, IntoSystem, Bundle) name in their
associated types and hidden methods, and those methods themselves. They are internal and may
change in any release (DECISIONS #36). The sealed traits cannot be implemented outside the
crate. pragma_core types appear in the API (ThreadPool,
Grain, the sources of EcsError::Job and
EcsError::Schedule); the crate re-exports the pragma_core it is built with, and a new
major version of pragma_core means a new major version of pragma_ecs. The minimum
supported Rust version (1.88) is raised only in a minor release.
§Integrating into an engine
The ECS is a library, not a framework: it has no game loop, no App or plugin system and no
global state. The engine owns the pieces and decides when they run. These are the decisions
to make, with the defaults the crate is designed and tested for;
examples/ecs_sim
is a complete headless loop built this way.
- One pool, owned by the engine. Create one
pragma_coreThreadPoolper process (through the re-exportedpragma_core, so the versions match) and pass&ThreadPooltoSchedule::runand to parallel queries. Callrunfrom a thread outside the pool, the main loop: that thread runs systems too while it waits. The rest of pragma.core’s advice (worker count, panic hook, shutdown order) is in its own “Integrating into an engine”. - The engine owns the
World. Between frames it changes the world directly: spawning (spawn_bundleputs an entity straight into its final table), inserting resources, loading a level. During a frame the world belongs to the schedule: systems see it only through their parameters, which is what lets them run in parallel. - Build schedules once.
ScheduleBuilder::buildvalidates every system at startup: two systems that conflict without an order areEcsError::AmbiguousOrdernaming both and the data. Stages (input, update, late update) are several schedules run one after another over the same world; a sequential loop of systems ports as oneorderedschedule. Rebuild only when the set of systems changes. - Resources are the boundary. Feed the frame through resources set before
run(the time step, input, handles of loaded assets) and read results after it (resources, or read-only queries over&Worldfor rendering, also in parallel withQuery::par_for_each). A resource a system needs but the world lacks isEcsError::MissingResourcebefore any system runs. - Structural changes and events take effect at the sync point.
CommandsandEventWriterrecord into the system’s own buffers; at the end ofSchedule::runthe events are published and the commands applied, system after system in registration order. A spawned entity gets its id only then, and readers see an event from the next frame on. Code outside systems sends events withWorld::send_event. - Errors to react to. A system that failed or panicked
(
EcsError::SystemFailed,EcsError::SystemPanicked) stops the systems ordered after it in that frame and loses its deferred commands;EcsError::CommandFailedmeans memory ran out while applying commands. In all three the world and the schedule stay consistent and usable: report and run the next frame.EcsError::OutOfMemoryfrom a world operation leaves the world unchanged. - Determinism. A frame gives the same world on any number of workers as long as the
systems do: no results that depend on completion order or on wall-clock time, parallel
iteration (
View::par_for_each_mut) writing only its own rows,Entity::to_bitsrather than addresses for anything hashed or sent over the network. Check it the wayecs_sim --verifydoes: the same scenario on 1 and N workers must give the same state hash. - Shutdown. Drop the schedules and the world before the pool (struct fields drop in declaration order); a schedule holds no thread and no reference into the world.
The skeleton of such an engine:
use pragma_ecs::pragma_core::prelude::{PoolConfig, ThreadPool};
use pragma_ecs::{Commands, EcsError, Query, Res, ResMut, Schedule, View, World};
struct Position(f32, f32);
struct Velocity(f32, f32);
struct Dt(f32);
#[derive(Default)]
struct Input {
fire: bool,
}
fn shoot(input: Res<Input>, mut commands: Commands) -> Result<(), EcsError> {
if input.fire {
commands.spawn_bundle((Position(0.0, 0.0), Velocity(10.0, 0.0)))?;
}
Ok(())
}
fn movement(mut bodies: View<(&mut Position, &Velocity)>, dt: Res<Dt>) -> Result<(), EcsError> {
for (mut position, velocity) in bodies.iter_mut()? {
position.0 += velocity.0 * dt.0;
position.1 += velocity.1 * dt.0;
}
Ok(())
}
struct Engine {
update: Schedule,
world: World,
/// Built once: its archetype cache follows the world between frames.
render: Query<&'static Position>,
pool: ThreadPool, // dropped last
}
impl Engine {
fn new() -> Result<Self, Box<dyn std::error::Error>> {
let mut world = World::new();
world.insert_resource(Dt(1.0 / 60.0));
world.insert_resource(Input::default());
for i in 0..100 {
world.spawn_bundle((Position(i as f32, 0.0), Velocity(0.0, 1.0)))?;
}
let mut builder = Schedule::builder();
builder.add_system("shoot", shoot)?;
builder.add_system("movement", movement)?;
let update = builder.build()?; // ordering mistakes fail here, at startup
let pool = ThreadPool::new(PoolConfig::default())?;
Ok(Self { update, world, render: Query::new()?, pool })
}
/// One frame; returns the number of bodies drawn.
fn frame(&mut self, dt: f32, fire: bool) -> Result<usize, EcsError> {
// 1. Input and time go in as resources.
if let Some(time) = self.world.resource_mut::<Dt>() {
time.0 = dt;
}
if let Some(input) = self.world.resource_mut::<Input>() {
input.fire = fire;
}
// 2. The frame: systems in parallel, then the sync point.
match self.update.run(&mut self.world, &self.pool) {
Ok(()) => {}
Err(
err @ (EcsError::SystemFailed { .. }
| EcsError::SystemPanicked { .. }
| EcsError::CommandFailed { .. }),
) => eprintln!("frame: {err}"), // the world stays consistent
Err(other) => return Err(other),
}
// 3. Rendering reads the world after the frame.
Ok(self.render.iter(&self.world)?.count())
}
}
let mut engine = Engine::new()?;
assert_eq!(engine.frame(1.0 / 60.0, false)?, 100);
// The bullet is spawned at the sync point, so rendering after the frame already sees it.
assert_eq!(engine.frame(1.0 / 60.0, true)?, 101);
assert_eq!(engine.frame(1.0 / 60.0, false)?, 101);Re-exports§
pub use pragma_core;
Structs§
- Access
- The component and resource types a query or a system reads and writes.
- Added
- A
QueryFilter: only entities whoseTcomponent was added since the query (or the system) last ran (DECISIONS #27, #28). Reads the change ticks ofT. - Changed
- A
QueryFilter: only entities whoseTcomponent was added or changed since the query (or the system) last ran (DECISIONS #27, #28). Reads the change ticks ofT. - Commands
- A system parameter that records structural changes: spawning and despawning entities, inserting and removing components (DECISIONS #23).
- Entity
- A generational entity id: (index, generation).
- Entity
Commands - Commands on one entity: an existing one (
Commands::entity) or one being spawned (Commands::spawn). The methods chain:commands.spawn()?.insert(a)?.insert(b)?. - Event
Cursor - A reading position in an
Events<T>: the id of the next event to read. AnEventReaderkeeps one per system; outside systems, keep your own. - Event
Iter - The iterator of
Events::iterandEventCursor::read. - Event
Reader - A system parameter that reads the events of type
Tthe system has not read yet, from theEvents<T>resource (a resource read: it conflicts only with a system that takesResMut<Events<T>>). The reading position is kept per system and reset for another world. See theEventWriterexample. - Event
Writer - A system parameter that sends events of type
T. The events are kept in the system’s own buffer and published at the sync point, at the end of the frame, in system registration order (DECISIONS #26): a writer declares no access, so it never orders systems, and readers see the events from the next frame on. - Events
- The events of type
T: a resource of theWorldwith two buffers, the events of the last two updates (DECISIONS #26). - Mut
- A component borrowed for writing by a query or a view (
&mut Tin the query): dereferences toT, and a mutable dereference marks the component as changed, so thatChanged<T>sees exactly the rows that were written (DECISIONS #28). - Query
- A typed query: the entities that have the components
Qasks for and pass the filterF. - Query
Iter - The iterator of
Query::iter: the matching entities of a shared world. - Query
Iter Mut - The iterator of
Query::iter_mut: the matching entities of an exclusively borrowed world. - Res
- A resource borrowed for reading:
World::resource, or a system parameter that reads resourceT. - ResMut
- A resource borrowed for writing: a system parameter that writes resource
T. See theResexample. - Schedule
- Systems on the
pragma_coreframe graph: built once (Schedule::builder), run every frame (Schedule::run). - Schedule
Builder - Builds a
Schedule(seeSchedule::builder). - System
Config - The explicit order of a system relative to others, given to
ScheduleBuilder::add_system_with. - View
- A system parameter: the entities that have the components
Qasks for and pass the filterF, in the frame’s world. - View
Iter - The iterator of
View::iterandView::iter_mut. - With
- A
QueryFilter: only entities that have aTcomponent, whose value is not read. - Without
- A
QueryFilter: only entities that do not have aTcomponent. - World
- A container of entities, their components and resources.
Enums§
- Access
Kind - How a query or a system uses a component or resource type.
- EcsError
- An error of an ECS operation.
Traits§
- Bundle
- A set of components added to an entity together: a tuple of 0 to 12 components.
- Component
- Data attached to an entity.
- Event
- A message sent from one system to others:
EventWriter<T>sends,EventReader<T>reads,Events<T>stores. - Into
System - A function that can be added to a
Scheduleas a system. - Query
Data - What a
Queryyields for every matching entity. - Query
Filter - Restricts the entities a
Queryvisits. - Read
Only Query Data - A
QueryDatathat only reads, so it can run on a shared&World(Query::iter,Query::par_for_each). - Resource
- A singleton stored in the
World, one value per type. - System
Output - What a system function may return:
()orResult<(), E>for any error typeE(anEcsError, aBox<dyn Error + Send + Sync>, a game’s own error type). - System
Param - A parameter of a system function:
View<Q, F>,Res<T>,ResMut<T>,Commands,EventWriter<T>orEventReader<T>.