Expand description
A high-performance, archetype-based Entity Component System (ECS) for Rust.
freecs provides a table-based storage system where entities with identical component sets are stored together in contiguous memory (Structure of Arrays layout), optimizing for cache coherency and SIMD operations.
§Key Features
- Zero-cost Abstractions: Fully statically dispatched, no custom traits
- Parallel Processing: Multi-threaded iteration using Rayon (automatically enabled on non-WASM platforms)
- Sparse Set Tags: Deterministic, generation-checked markers that don’t fragment archetypes
- Command Buffers: Queue structural changes during iteration
- Change Detection: Track component modifications for incremental updates
- Events: Sequence-numbered channels with exactly-once cursor consumption
- Structural Change Log: Cursor-based log of spawns, despawns, component moves, and tag flips
- Multi-World: Split components across multiple worlds for >64 component types
- Dynamic Worlds (optional
dynamicfeature): the primary entry point. Runtime component registration with bundle spawns, typed queries, joins, prepared queries, snapshots and deltas, same storage underneath; new surface lands here first, and the macro tier stays the executable specification it is tested against
The ecs! macro generates the entire ECS at compile time using only plain data structures, functions, and zero unsafe code.
§Quick Start
use freecs::{ecs, Entity};
// First, define components (must implement Default)
#[derive(Default, Clone, Debug)]
pub struct Position { pub x: f32, pub y: f32 }
#[derive(Default, Clone, Debug)]
pub struct Velocity { pub x: f32, pub y: f32 }
#[derive(Default, Clone, Debug)]
pub struct Health { pub value: f32 }
// Then, create a world with the `ecs!` macro
ecs! {
World {
position: Position => POSITION,
velocity: Velocity => VELOCITY,
health: Health => HEALTH,
}
Tags {
player => PLAYER,
enemy => ENEMY,
}
Events {
collision: CollisionEvent,
}
Resources {
delta_time: f32
}
}
#[derive(Debug, Clone)]
pub struct CollisionEvent {
pub entity_a: Entity,
pub entity_b: Entity,
}§Entity and Component Access
let mut world = World::default();
// Spawn entities with components by mask
let entity = world.spawn_entities(POSITION | VELOCITY, 1)[0];
// Lookup and modify a component using generated methods
if let Some(pos) = world.get_position_mut(entity) {
pos.x += 1.0;
}
// Read components
if let Some(pos) = world.get_position(entity) {
println!("Position: ({}, {})", pos.x, pos.y);
}
// Set components (adds if not present)
world.set_position(entity, Position { x: 10.0, y: 20.0 });
world.set_velocity(entity, Velocity { x: 1.0, y: 0.0 });
// Add new components to an entity by mask
world.add_components(entity, HEALTH | VELOCITY);
// Or use the generated add methods
world.add_health(entity);
// Remove components from an entity by mask
world.remove_components(entity, VELOCITY | POSITION);
// Or use the generated remove methods
world.remove_velocity(entity);
// Check if entity has components
if world.entity_has_position(entity) {
println!("Entity has position component");
}
// Query all entities
let entities = world.get_all_entities();
println!("All entities: {entities:?}");
// Query entities, iterating over all entities matching the component mask
let entities = world.query_entities(POSITION | VELOCITY);
// Query for the first entity matching the component mask, returning early when found
let player = world.query_first_entity(POSITION | VELOCITY);§Tags
Tags are lightweight markers that don’t cause archetype fragmentation:
// Add tags to entities
world.add_player(entity);
// Check if entity has a tag
if world.has_player(entity) {
println!("Entity is a player");
}
// Query entities by tag
for entity in world.query_player() {
println!("Player: {:?}", entity);
}
// Remove tags
world.remove_player(entity);§Events
Events provide type-safe communication between systems:
// Send events
world.send_collision(CollisionEvent {
entity_a: entity,
entity_b: entity,
});
// Process events in systems
for event in world.collect_collision() {
println!("Collision: {:?} and {:?}", event.entity_a, event.entity_b);
}
// Clean up events and increment tick at end of frame
world.step();§Systems
Systems are functions that query entities and transform their components. For maximum performance, use the query builder API for direct table access:
fn physics_system(world: &mut World) {
let dt = world.resources.delta_time;
// Method 1: High-performance query builder (recommended)
world.query_mut()
.with(POSITION | VELOCITY)
.iter(|entity, table, idx| {
table.position[idx].x += table.velocity[idx].x * dt;
table.position[idx].y += table.velocity[idx].y * dt;
});
// Method 2: Per-entity lookups (simpler but slower)
let entities: Vec<_> = world.query_entities(POSITION | VELOCITY).collect();
for entity in entities {
if let Some(velocity) = world.get_velocity(entity).cloned() {
if let Some(position) = world.get_position_mut(entity) {
position.x += velocity.x * dt;
position.y += velocity.y * dt;
}
}
}
}§Parallel Processing
Process large entity counts across multiple CPU cores using Rayon. Parallel iteration is automatically available on non-WASM platforms:
use freecs::rayon::prelude::*;
fn parallel_physics(world: &mut World) {
let dt = world.resources.delta_time;
world.par_for_each_mut(POSITION | VELOCITY, 0, |entity, table, idx| {
table.position[idx].x += table.velocity[idx].x * dt;
table.position[idx].y += table.velocity[idx].y * dt;
});
}Parallel iteration is best suited for processing 100K+ entities with non-trivial per-entity computation.
§Command Buffers
Queue structural changes during iteration to avoid borrow conflicts:
// Queue despawns during iteration
let entities_to_despawn: Vec<Entity> = world
.query_entities(HEALTH)
.filter(|&entity| {
world.get_health(entity).map_or(false, |h| h.value <= 0.0)
})
.collect();
for entity in entities_to_despawn {
world.queue_despawn_entity(entity);
}
// Apply all queued commands at once
world.apply_commands();§Change Detection
Track which components have been modified since the last frame:
// Process only entities with changed components
world.for_each_mut_changed(POSITION, 0, |entity, table, idx| {
// Only processes entities where position changed since last step()
});
// Automatically increments tick counter
world.step();Writes through set_*, get_*_mut, and modify_* mark the slot as
changed, as do spawns and component add/remove migrations. Raw table
access does not mark. Changed queries skip whole tables that no write
has touched since the last step(), using a per-table high-water tick
per component.
§System Scheduling
Organize systems into a schedule:
let mut world = World::default();
let mut schedule = Schedule::new();
schedule
.push("input", input_system)
.push("physics", physics_system)
.push_readonly("render", render_system);
// Game loop
loop {
schedule.run(&mut world);
world.step();
}§Entity Builder
let mut world = World::default();
let entities = EntityBuilder::new()
.with_position(Position { x: 1.0, y: 2.0 })
.with_velocity(Velocity { x: 0.0, y: 1.0 })
.spawn(&mut world, 2);
// Access the spawned entities
let first_pos = world.get_position(entities[0]).unwrap();
assert_eq!(first_pos.x, 1.0);§Advanced Features
§Batch Spawning
// Spawn with initialization callback
let entities = world.spawn_batch(POSITION | VELOCITY, 1000, |table, idx| {
table.position[idx] = Position { x: idx as f32, y: 0.0 };
table.velocity[idx] = Velocity { x: 1.0, y: 0.0 };
});§Per-Component Iteration
// Iterate over single component
world.iter_position_mut(|_entity, position| {
position.x += 1.0;
});
// Slice-based iteration (most efficient)
for slice in world.iter_position_slices_mut() {
for position in slice {
position.x *= 2.0;
}
}§Low-Level Iteration
// Include/exclude with masks
world.for_each_mut(POSITION | VELOCITY, PLAYER, |entity, table, idx| {
// Process non-player entities
table.position[idx].x += table.velocity[idx].x;
});§Advanced Command Operations
// Queue batch operations
world.queue_spawn_entities(POSITION, 100);
world.queue_set_position(entity, Position { x: 10.0, y: 20.0 });
world.queue_add_player(entity);
// Check command buffer status
if world.command_count() > 100 {
world.apply_commands();
}
// Clear without applying
world.clear_commands();§Event Management
// Peek at events without consuming
if let Some(event) = world.peek_collision() {
println!("Next collision: {:?}", event.entity_a);
}
// Check event count
if !world.is_empty_collision() {
let count = world.len_collision();
println!("Processing {} events", count);
}
// Cursor-based, exactly-once consumption: record where you left off,
// read everything newer, then advance your cursor.
let mut cursor = 0;
for event in world.read_collision_since(cursor) {
// Process event
}
cursor = world.sequence_collision();
assert!(world.read_collision_since(cursor).is_empty());§Conditional Compilation
Both components and resources support #[cfg(...)] attributes for conditional compilation.
This is useful for debug-only components, optional features, or platform-specific functionality:
ecs! {
World {
position: Position => POSITION,
velocity: Velocity => VELOCITY,
#[cfg(debug_assertions)]
debug_info: DebugInfo => DEBUG_INFO,
#[cfg(feature = "physics")]
rigid_body: RigidBody => RIGID_BODY,
}
Resources {
delta_time: f32,
#[cfg(feature = "audio")]
audio_engine: AudioEngine,
}
}When a component or resource has a #[cfg(...)] attribute, all related generated code
(struct fields, accessor methods, mask constants, etc.) is conditionally compiled.
Re-exports§
Modules§
- dynamic
- Runtime-registered components over the same archetype kernel.
- system_
param - Bevy-style system parameters over
DynWorld: functions whose arguments areRes,ResMut, andQueryresolve into runnable systems the existingScheduleaccepts, with nounsafeand no new executor.
Macros§
- dynamic_
accessors - Generates the macro-world accessor ergonomics over the keyed tier: a
keys struct holding one
dynamic::ComponentKeyordynamic::TagKeyper entry, aresolveconstructor registering them in declaration order, and named methods on your wrapper type —get_<name>/get_<name>_mut/set_<name>/remove_<name>/has_<name>per component,add_<name>/remove_<name>/has_<name>/query_<name>per tag. The wrapper must expose the named world and keys fields. Accessors run at keyed speed and stamp change ticks exactly like the generated macro world’s. Component and tag names share one method namespace (remove_andhas_exist for both), so an identifier may appear in only one of the two blocks; reusing one is a duplicate-method compile error. - dynamic_
schema - Declares a dynamic world’s schema in one place: the mask constants (bits
assigned in declaration order, which is the registration order and
therefore the snapshot schema) and the registration function that builds
a
dynamic::ComponentRegistryin that exact order, asserting each key’s mask against its constant. Declare every component on every build configuration, and only ever append, so masks stay identical across feature sets and saves stay loadable. - dynamic_
worlds - Declares a
dynamic::DynEcsgroup’s member worlds in one place: the index constants in declaration order and the build function that adds each member’s registry at its asserted index, replacing the hand-written const-add-assert dance. Pair each member with a registration function, typically fromdynamic_schema!. Apps extending a built group add their own member withdynamic::DynEcs::add_world_at. - ecs
- ecs_
impl - ecs_
multi_ impl - table_
has_ components
Structs§
- Archetype
Edges - Archetype graph edges for one table: which table an entity lands in when a single component bit is added or removed, plus memoized targets for multi-bit changes. Shared by the macro-generated worlds and the dynamic world, since none of it depends on component types.
- Archetype
Routing - Registers a newly pushed table with the archetype routing structures:
inserts the mask lookup, appends the table’s edge record, extends every
query-cache entry the new table satisfies, and wires single-component
edges from existing tables toward the new one.
table_masksmust iterate every table including the new one, in index order. - Entity
- Entity
Allocator - Allocates generational entity handles and tracks which handles are live.
- Entity
Location - Entity
Locations - Entity
Slot - Liveness record for one entity id: the generation currently associated with the id and whether that handle is live.
- Event
Channel - A sequence-numbered event channel for inter-system communication.
- Schedule
- Sparse
TagSet - A sparse set of entities backing one tag.
- Stages
- An ordered set of named stages, each its own
Schedule, for programs assembled from independent parts: the app declares the stage order once, and each part pushes systems into stages by name, so composition stays deterministic without labels or ordering constraints. Stages run in declaration order; systems within a stage run in push order. Plain data, inspectable throughstages. - Structural
Change - One structural mutation recorded by the world: a spawn, a despawn, or a
component add or remove.
maskholds the components involved: the full mask for spawns and despawns, the delta for adds and removes. Consumers track their ownsequencecursor viastructural_changes_sinceand the owner trims consumed entries withtrim_structural_log.
Enums§
Constants§
- EVENT_
CHANNEL_ CAPACITY - Backstop for event channels whose events are never consumed or expired. When the buffer reaches this length the oldest events are dropped, so a channel with no consumer stays bounded instead of leaking.
- STRUCTURAL_
LOG_ CAPACITY - Backstop for worlds whose structural log is never consumed. When the log reaches this length it is cleared wholesale, so a world with no consumer stays bounded instead of leaking. Consumers that drain every frame never come near it.
Functions§
- archetype_
cached_ tables - Returns the memoized list of table indices whose masks contain
mask, computing and caching it on first use. Taking the cache and the table masks as separate parameters keeps the borrows disjoint, so callers can mutate tables while holding the returned slice. - archetype_
register_ table - tick_
is_ newer - Returns true if
tickwas stamped aftersince_tick, treating ticks as a wrapping sequence so detection keeps working afteru32overflow.