Skip to main content

Crate pragma_ecs

Crate pragma_ecs 

Source
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.

ConceptItems
Entities and componentsWorld, 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
QueriesQuery over QueryData (&T, &mut T as Mut<T>, Option<&T>, Entity, tuples) and QueryFilter (With, Without, Added, Changed); declared Access
Systemsplain functions of View, Res, ResMut, Commands, EventWriter, EventReader; a Schedule built by a ScheduleBuilder
Deferred changesCommands, EntityCommands, Events, EventCursor, applied at the sync point at the end of Schedule::run
ErrorsEcsError (#[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.

  1. One pool, owned by the engine. Create one pragma_core ThreadPool per process (through the re-exported pragma_core, so the versions match) and pass &ThreadPool to Schedule::run and to parallel queries. Call run from 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”.
  2. The engine owns the World. Between frames it changes the world directly: spawning (spawn_bundle puts 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.
  3. Build schedules once. ScheduleBuilder::build validates every system at startup: two systems that conflict without an order are EcsError::AmbiguousOrder naming 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 one ordered schedule. Rebuild only when the set of systems changes.
  4. 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 &World for rendering, also in parallel with Query::par_for_each). A resource a system needs but the world lacks is EcsError::MissingResource before any system runs.
  5. Structural changes and events take effect at the sync point. Commands and EventWriter record into the system’s own buffers; at the end of Schedule::run the 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 with World::send_event.
  6. 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::CommandFailed means 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::OutOfMemory from a world operation leaves the world unchanged.
  7. 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_bits rather than addresses for anything hashed or sent over the network. Check it the way ecs_sim --verify does: the same scenario on 1 and N workers must give the same state hash.
  8. 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 whose T component was added since the query (or the system) last ran (DECISIONS #27, #28). Reads the change ticks of T.
Changed
A QueryFilter: only entities whose T component was added or changed since the query (or the system) last ran (DECISIONS #27, #28). Reads the change ticks of T.
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).
EntityCommands
Commands on one entity: an existing one (Commands::entity) or one being spawned (Commands::spawn). The methods chain: commands.spawn()?.insert(a)?.insert(b)?.
EventCursor
A reading position in an Events<T>: the id of the next event to read. An EventReader keeps one per system; outside systems, keep your own.
EventIter
The iterator of Events::iter and EventCursor::read.
EventReader
A system parameter that reads the events of type T the system has not read yet, from the Events<T> resource (a resource read: it conflicts only with a system that takes ResMut<Events<T>>). The reading position is kept per system and reset for another world. See the EventWriter example.
EventWriter
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 the World with two buffers, the events of the last two updates (DECISIONS #26).
Mut
A component borrowed for writing by a query or a view (&mut T in the query): dereferences to T, and a mutable dereference marks the component as changed, so that Changed<T> sees exactly the rows that were written (DECISIONS #28).
Query
A typed query: the entities that have the components Q asks for and pass the filter F.
QueryIter
The iterator of Query::iter: the matching entities of a shared world.
QueryIterMut
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 resource T.
ResMut
A resource borrowed for writing: a system parameter that writes resource T. See the Res example.
Schedule
Systems on the pragma_core frame graph: built once (Schedule::builder), run every frame (Schedule::run).
ScheduleBuilder
Builds a Schedule (see Schedule::builder).
SystemConfig
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 Q asks for and pass the filter F, in the frame’s world.
ViewIter
The iterator of View::iter and View::iter_mut.
With
A QueryFilter: only entities that have a T component, whose value is not read.
Without
A QueryFilter: only entities that do not have a T component.
World
A container of entities, their components and resources.

Enums§

AccessKind
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.
IntoSystem
A function that can be added to a Schedule as a system.
QueryData
What a Query yields for every matching entity.
QueryFilter
Restricts the entities a Query visits.
ReadOnlyQueryData
A QueryData that 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.
SystemOutput
What a system function may return: () or Result<(), E> for any error type E (an EcsError, a Box<dyn Error + Send + Sync>, a game’s own error type).
SystemParam
A parameter of a system function: View<Q, F>, Res<T>, ResMut<T>, Commands, EventWriter<T> or EventReader<T>.