A high-performance, archetype-based Entity Component System (ECS) for Rust
Key Features:
- Zero-cost abstractions with static dispatch
- Multi-threaded parallel processing using Rayon (automatically enabled on non-WASM platforms)
- Sparse set tags that don't fragment archetypes
- Command buffers for deferred structural changes
- Change detection for incremental updates
- Type-safe double-buffered event system
- Multi-world support for >64 component types with shared entity allocator
The ecs! macro generates the entire ECS at compile time using only plain data structures, functions, and zero unsafe code.
Add this to your Cargo.toml:
[dependencies]
freecs = "2.0.1"And in main.rs:
use freecs::{ecs, Entity};
ecs! {
World {
position: Position => POSITION,
velocity: Velocity => VELOCITY,
health: Health => HEALTH,
}
Tags {
player => PLAYER,
enemy => ENEMY,
}
Events {
collision: CollisionEvent,
}
Resources {
delta_time: f32
}
}
pub fn main() {
let mut world = World::default();
// Spawn entities with components
let _entity = world.spawn_entities(POSITION | VELOCITY, 1)[0];
// Or use the entity builder
let entity = EntityBuilder::new()
.with_position(Position { x: 1.0, y: 2.0 })
.spawn(&mut world, 1)[0];
// Read components using the generated methods
let position = world.get_position(entity);
println!("Position: {:?}", position);
// Set components (adds if not present)
world.set_position(entity, Position { x: 1.0, y: 2.0 });
// Mutate a component
if let Some(position) = world.get_position_mut(entity) {
position.x += 1.0;
}
// Get an entity's component mask
let _component_mask = world.component_mask(entity).unwrap();
// Add a new component to an entity
world.add_components(entity, HEALTH);
// Or use the generated add method
world.add_health(entity);
// Query all entities
let _entities = world.get_all_entities();
// Query all entities with a specific component
let _players = world.query_entities(POSITION | VELOCITY | HEALTH);
// Query the first entity with a specific component,
// returning early instead of checking remaining entities
let _first_player_entity = world.query_first_entity(POSITION | VELOCITY | HEALTH);
// Remove a component from an entity
world.remove_components(entity, HEALTH);
// Or use the generated remove method
world.remove_health(entity);
// Check if entity has components
if world.entity_has_position(entity) {
println!("Entity has position component");
}
// Add tags to entities (lightweight markers)
world.add_player(entity);
// Check if entity has a tag
if world.has_player(entity) {
println!("Entity is a player");
}
// Remove tags
world.remove_player(entity);
// Send events
world.send_collision(CollisionEvent {
entity_a: entity,
entity_b: entity,
});
// Systems are functions that transform component data
systems::run_systems(&mut world);
// Despawn entities, freeing their table slots for reuse
world.despawn_entities(&[entity]);
}
use components::*;
mod components {
#[derive(Default, Debug, Clone, Copy)]
pub struct Position {
pub x: f32,
pub y: f32,
}
#[derive(Default, Debug, Clone, Copy)]
pub struct Velocity {
pub x: f32,
pub y: f32,
}
#[derive(Default, Debug, Clone, Copy)]
pub struct Health {
pub value: f32,
}
}
use events::*;
mod events {
use super::*;
#[derive(Debug, Clone)]
pub struct CollisionEvent {
pub entity_a: Entity,
pub entity_b: Entity,
}
}
mod systems {
use super::*;
pub fn run_systems(world: &mut World) {
// Systems use queries and component accessors
example_system(world);
update_positions_system(world);
collision_handler_system(world);
health_system(world);
}
fn example_system(world: &mut World) {
for entity in world.query_entities(POSITION | VELOCITY) {
if let Some(position) = world.get_position_mut(entity) {
position.x += 1.0;
}
}
}
fn update_positions_system(world: &mut World) {
let dt = world.resources.delta_time;
// Collect entities with their velocities first to avoid borrow conflicts
let updates: Vec<(Entity, Velocity)> = world
.query_entities(POSITION | VELOCITY)
.into_iter()
.filter_map(|entity| {
world.get_velocity(entity).map(|vel| (entity, *vel))
})
.collect();
// Now update positions
for (entity, vel) in updates {
if let Some(pos) = world.get_position_mut(entity) {
pos.x += vel.x * dt;
pos.y += vel.y * dt;
}
}
}
fn collision_handler_system(world: &mut World) {
// Process collision events
for event in world.collect_collision() {
println!("Collision detected between {:?} and {:?}", event.entity_a, event.entity_b);
}
}
fn health_system(world: &mut World) {
for entity in world.query_entities(HEALTH) {
if let Some(health) = world.get_health_mut(entity) {
health.value *= 0.98;
}
}
}
}The ecs! macro generates type-safe methods for each component:
// For each component, you get:
world.get_position(entity) // -> Option<&Position>
world.get_position_mut(entity) // -> Option<&mut Position>
world.modify_position(entity, f) // -> Option<R> - mutate via closure, returns closure result
world.set_position(entity, pos) // Sets or adds the component
world.add_position(entity) // Adds with default value
world.remove_position(entity) // Removes the component
world.entity_has_position(entity) // Checks if entity has componentThe modify_<component> methods allow you to mutate a component via a closure, which automatically releases the borrow when done. This is useful when you need to mutate a component and then immediately access the world again:
// Instead of this pattern (requires explicit drop):
let player = world.get_player_mut(entity).unwrap();
player.stamina -= 10.0;
let _ = player; // Must drop to release borrow
let pos = world.get_position(entity);
// Use modify for cleaner code:
world.modify_player(entity, |p| p.stamina -= 10.0);
let pos = world.get_position(entity); // No drop needed
// The closure can return values:
let old_health = world.modify_health(entity, |h| {
let old = h.value;
h.value = 100.0;
old
});Systems are functions that query entities and transform their components:
pub fn update_global_transforms_system(world: &mut World) {
world
.query_entities(LOCAL_TRANSFORM | GLOBAL_TRANSFORM)
.into_iter()
.for_each(|entity| {
// The entities we queried for are guaranteed to have
// a local transform and global transform here
let new_global_transform = query_global_transform(world, entity);
let global_transform = world.get_global_transform_mut(entity).unwrap();
*global_transform = GlobalTransform(new_global_transform);
});
}
pub fn query_global_transform(world: &World, entity: EntityId) -> nalgebra_glm::Mat4 {
let Some(local_transform) = world.get_local_transform(entity) else {
return nalgebra_glm::Mat4::identity();
};
if let Some(Parent(parent)) = world.get_parent(entity) {
query_global_transform(world, *parent) * local_transform
} else {
local_transform
}
}For performance-critical systems with large numbers of entities, you can batch process components:
fn batched_physics_system(world: &mut World) {
let dt = world.resources.delta_time;
// Collect entity data
let mut entities: Vec<(Entity, Position, Velocity)> = world
.query_entities(POSITION | VELOCITY)
.into_iter()
.filter_map(|entity| {
match (world.get_position(entity), world.get_velocity(entity)) {
(Some(pos), Some(vel)) => Some((entity, *pos, *vel)),
_ => None
}
})
.collect();
// Process all entities
for (_, pos, vel) in &mut entities {
pos.x += vel.x * dt;
pos.y += vel.y * dt;
}
// Write back results
for (entity, new_pos, _) in entities {
world.set_position(entity, new_pos);
}
}This approach minimizes borrowing conflicts and can improve performance by processing data in batches.
Events provide a type-safe way to communicate between systems:
ecs! {
World {
position: Position => POSITION,
velocity: Velocity => VELOCITY,
}
Events {
collision: CollisionEvent,
damage: DamageEvent,
}
}
use events::*;
mod events {
use super::*;
#[derive(Debug, Clone)]
pub struct CollisionEvent {
pub entity_a: Entity,
pub entity_b: Entity,
}
#[derive(Debug, Clone)]
pub struct DamageEvent {
pub entity: Entity,
pub amount: f32,
}
}
fn physics_system(world: &mut World) {
for entity_a in world.query_entities(POSITION) {
for entity_b in world.query_entities(POSITION) {
if check_collision(entity_a, entity_b) {
world.send_collision(CollisionEvent { entity_a, entity_b });
}
}
}
}
fn damage_system(world: &mut World) {
for event in world.collect_collision() {
world.send_damage(DamageEvent {
entity: event.entity_a,
amount: 10.0
});
}
}
fn health_system(world: &mut World) {
for event in world.collect_damage() {
if let Some(health) = world.get_health_mut(event.entity) {
health.value -= event.amount;
}
}
}Each event type gets these generated methods:
send_<event>(event)- Queue an eventread_<event>()- Get an iterator over all queued eventscollect_<event>()- Collect events into a Vec (eliminates boilerplate)peek_<event>()- Get reference to first event without consumingdrain_<event>()- Consume all events (takes ownership)update_<event>()- Swap buffers (old events cleared, current becomes previous)clear_<event>()- Immediately clear all eventslen_<event>()- Get count of all queued eventsis_empty_<event>()- Check if queue is empty
Call world.step() at the end of each frame to handle event cleanup:
loop {
input_system(&mut world);
physics_system(&mut world);
collision_system(&mut world);
world.step(); // Cleans up events and increments tick counter
}The step() method handles event lifecycle and tick counter automatically. For fine-grained control, you can use update_<event>() to update individual event types.
Events use double buffering to prevent systems from missing events in parallel execution. Events persist for 2 frames by default, then auto-clear on the next step() call. For immediate clearing, use clear_<event>().
For maximum performance, use the query builder which provides direct table access:
fn physics_update_system(world: &mut World) {
let dt = world.resources.delta_time;
world.query()
.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;
});
}This eliminates per-entity lookups and provides cache-friendly sequential access.
The query builder also supports filtering:
// Exclude entities with specific components
world.query()
.with(POSITION | VELOCITY)
.without(PLAYER)
.iter(|entity, table, idx| {
// Only processes entities that have position and velocity but NOT player
});You can also use the lower-level iteration methods directly:
// Mutable iteration
world.for_each_mut(POSITION | VELOCITY, 0, |entity, table, idx| {
table.position[idx].x += table.velocity[idx].x;
});
// Read-only iteration
for entity in world.query_entities(POSITION | VELOCITY) {
let pos = world.get_position(entity).unwrap();
let vel = world.get_velocity(entity).unwrap();
println!("Entity {:?} at ({}, {})", entity, pos.x, pos.y);
}Spawn multiple entities efficiently (5.5x faster than individual spawns):
// Method 1: spawn_batch 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 };
});
// Method 2: spawn_entities (uses component defaults)
let entities = world.spawn_entities(POSITION | VELOCITY, 1000);
// Method 3: entity builder for small batches
let entities = EntityBuilder::new()
.with_position(Position { x: 0.0, y: 0.0 })
.with_velocity(Velocity { x: 1.0, y: 1.0 })
.spawn(&mut world, 100);Optimized iteration for single components:
world.for_each_position(|position| {
position.x += 1.0;
});
world.for_each_position_mut(|position| {
position.y *= 0.99;
});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_system(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;
});
}Best for 100K+ entities with non-trivial per-entity computation. For smaller entity counts, serial iteration may be more efficient due to parallelization overhead.
Note: Parallel methods are only available when targeting non-WASM platforms. On WASM targets, use the regular serial iteration methods instead.
Tags are lightweight markers stored in sparse sets rather than archetypes. This means adding/removing tags doesn't trigger archetype migrations, avoiding fragmentation:
ecs! {
World {
position: Position => POSITION,
velocity: Velocity => VELOCITY,
}
Tags {
player => PLAYER,
enemy => ENEMY,
selected => SELECTED,
}
}
// Adding tags doesn't move entities between archetypes
world.add_player(entity);
world.add_selected(entity);
// Check if entity has a tag
if world.has_player(entity) {
println!("Entity is a player");
}
// Query entities by component and filter by tag
for entity in world.query_entities(POSITION | VELOCITY) {
if world.has_enemy(entity) {
// Process enemies
}
}
// Remove tags
world.remove_player(entity);
world.remove_selected(entity);Tags are perfect for:
- Runtime categorization (player, enemy, npc)
- Selection/highlighting states
- Temporary status flags
- Any marker that changes frequently
Command buffers allow you to queue structural changes (spawn, despawn, add/remove components) during iteration, then apply them all at once. This avoids borrowing conflicts and archetype invalidation during queries:
fn death_system(world: &mut World) {
// 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();
}
fn spawn_system(world: &mut World) {
// Queue entity spawns
for _ in 0..10 {
world.queue_spawn(POSITION | VELOCITY);
}
// Queue component additions
for entity in world.query_entities(POSITION) {
if should_add_health(entity) {
world.queue_add_components(entity, HEALTH);
}
}
// Queue component removals
for entity in world.query_entities(VELOCITY) {
if should_stop(entity) {
world.queue_remove_components(entity, VELOCITY);
}
}
world.apply_commands();
}Available command buffer operations:
queue_spawn(mask)- Queue entity spawnqueue_despawn_entity(entity)- Queue entity despawnqueue_add_components(entity, mask)- Queue component additionqueue_remove_components(entity, mask)- Queue component removalqueue_set_component(entity, component)- Queue component set/updateapply_commands()- Apply all queued commands
Track which components have been modified since the last frame. Useful for incremental updates, networking, or rendering optimizations:
fn render_system(world: &mut World) {
// Process only entities whose components changed since last step()
world.for_each_mut_changed(POSITION, 0, |entity, table, idx| {
// Only processes entities where position changed
update_sprite_position(&table.position[idx]);
});
}
fn network_sync_system(world: &mut World) {
// Sync changed entities to network clients
world.for_each_mut_changed(POSITION | VELOCITY, 0, |entity, table, idx| {
sync_to_network(entity, &table.position[idx], &table.velocity[idx]);
});
}
// At the end of your game loop
world.step(); // Increments tick counter and swaps event buffersChange detection tracks modifications at the component table level. Any mutation via get_*_mut() or table access marks that component slot as changed for the current tick.
Performance note: Change detection adds a small overhead. Only use it when you need to track changes.
Organize systems into a schedule for automatic execution:
use freecs::Schedule;
fn main() {
let mut world = World::default();
// Create separate schedules for game logic and rendering
let mut game_schedule = Schedule::new();
game_schedule
.push("input", input_system)
.push("physics", physics_system)
.push("collision", collision_system);
let mut render_schedule = Schedule::new();
render_schedule
.push_readonly("render_grid", render_grid)
.push_readonly("render_entities", render_entities);
// Game loop
loop {
game_schedule.run(&mut world); // Run game logic
render_schedule.run(&mut world); // Run rendering
world.step();
}
}
fn input_system(world: &mut World) {
// Handle input - mutates world state
}
fn physics_system(world: &mut World) {
// Update physics - mutates positions
}
fn collision_system(world: &mut World) {
// Check collisions - sends events
}
fn render_grid(world: &World) {
// Render grid - read-only
}
fn render_entities(world: &World) {
// Render entities - read-only
}Schedule API:
push(name, system)/push_readonly(name, system)- Append a mutable or read-only systeminsert_before(target, name, system)/insert_after(target, name, system)- Positional insertionreplace(name, system)- Swap a system in-place, preserving execution orderremove(name)- Remove a system by name (returnsbool)contains(name)/names()/len()/is_empty()- Introspection
All systems require a unique &'static str name. Duplicates panic at insertion time.
An entity builder is generated automatically:
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);
assert_eq!(world.get_position(entities[0]).unwrap().x, 1.0);
assert_eq!(world.get_position(entities[1]).unwrap().y, 2.0);For iterating over a single component type, specialized methods are generated:
// Read-only iteration
world.iter_position(|position| {
println!("Position: ({}, {})", position.x, position.y);
});
// Mutable iteration
world.iter_position_mut(|position| {
position.x += 1.0;
});
// Slice-based iteration (most efficient)
for slice in world.iter_position_slices() {
for position in slice {
println!("Position: ({}, {})", position.x, position.y);
}
}
for slice in world.iter_position_slices_mut() {
for position in slice {
position.x *= 2.0;
}
}
// Query entities with specific component
for entity in world.query_position() {
println!("Entity with position: {:?}", entity);
}Query entities by specific tags:
// Get all entities with a specific tag
for entity in world.query_player() {
println!("Player entity: {:?}", entity);
}
for entity in world.query_enemy() {
if let Some(pos) = world.get_position(entity) {
println!("Enemy at ({}, {})", pos.x, pos.y);
}
}Beyond the basic command buffer operations, you can queue additional operations:
// Queue batch spawns
world.queue_spawn_entities(POSITION | VELOCITY, 100);
// Queue batch despawns
let entities_to_remove = vec![entity1, entity2, entity3];
world.queue_despawn_entities(entities_to_remove);
// Queue component sets (generated per component)
world.queue_set_position(entity, Position { x: 10.0, y: 20.0 });
world.queue_set_velocity(entity, Velocity { x: 1.0, y: 0.0 });
// Queue tag operations
world.queue_add_player(entity);
world.queue_remove_enemy(entity);
// Check command buffer status
if world.command_count() > 100 {
world.apply_commands();
}
// Clear pending commands without applying
world.clear_commands();The query builder provides a fluent API for complex queries:
// Mutable query builder
world.query_mut()
.with(POSITION | VELOCITY)
.without(PLAYER)
.iter(|entity, table, idx| {
table.position[idx].x += table.velocity[idx].x;
});
// Read-only query builder
world.query()
.with(POSITION)
.without(ENEMY)
.iter(|entity, table, idx| {
println!("Position: ({}, {})", table.position[idx].x, table.position[idx].y);
});For maximum control, use the low-level iteration methods:
// Read-only iteration with include/exclude masks
world.for_each(POSITION | VELOCITY, PLAYER, |entity, table, idx| {
let pos = &table.position[idx];
let vel = &table.velocity[idx];
println!("Non-player entity at ({}, {})", pos.x, pos.y);
});
// Mutable iteration with include/exclude masks
world.for_each_mut(POSITION | VELOCITY, 0, |entity, table, idx| {
table.position[idx].x += table.velocity[idx].x;
table.position[idx].y += table.velocity[idx].y;
});
// Check if entity has multiple components
if world.entity_has_components(entity, POSITION | VELOCITY | HEALTH) {
println!("Entity has all required components");
}Query the current and previous tick counters for advanced change detection:
let current = world.current_tick();
let previous = world.last_tick();
// Process only entities changed since last frame
world.for_each_mut_changed(POSITION, 0, |entity, table, idx| {
sync_transform(entity, &table.position[idx]);
});
// Tick is automatically incremented by world.step()
world.step();Preview events without consuming them:
// Peek at the first event
if let Some(event) = world.peek_collision() {
println!("Next collision: {:?} and {:?}", event.entity_a, event.entity_b);
}
// Check if events exist
if !world.is_empty_collision() {
let count = world.len_collision();
println!("Processing {} collision events", count);
}
// Drain events (takes ownership)
for event in world.drain_collision() {
process_collision(event);
}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, enum variants, etc.) is conditionally compiled based on the feature flag or target configuration.
For projects exceeding 64 component types, you can split components across multiple independent worlds that share a single entity allocator. Each world retains full u64 bitmask performance (up to 64 components per world).
use freecs::{ecs, Entity, Schedule};
ecs! {
GameEcs {
CoreWorld {
position: Position => POSITION,
velocity: Velocity => VELOCITY,
}
RenderWorld {
sprite: Sprite => SPRITE,
color: Color => COLOR,
}
}
Tags { player => PLAYER }
Events { collision: CollisionEvent }
GameResources { delta_time: f32 }
}Entities are spawned from the shared allocator and can have components in any combination of worlds:
let mut ecs = GameEcs::default();
// Spawn an entity and add components across worlds
let entity = ecs.spawn();
ecs.core_world.set_position(entity, Position { x: 0.0, y: 0.0 });
ecs.render_world.set_sprite(entity, Sprite { id: 1 });
// EntityBuilder spans worlds automatically
let entities = EntityBuilder::new()
.with_position(Position { x: 0.0, y: 0.0 })
.with_sprite(Sprite { id: 2 })
.spawn(&mut ecs, 1);
// Per-world queries run at full bitmask speed
ecs.core_world.for_each_mut(POSITION | VELOCITY, 0, |entity, table, idx| {
table.position[idx].x += table.velocity[idx].x;
});
// Cross-world access via split borrowing
let GameEcs { core_world, render_world, player, .. } = &mut ecs;
core_world.for_each(POSITION, 0, |entity, table, idx| {
if let Some(sprite) = render_world.get_sprite(entity) {
// Access components from both worlds
}
});
// Despawn cascades across all worlds
ecs.despawn(entity);Tags, events, resources, command buffers, and Schedule all work identically in multi-world mode. Mask constants (e.g. POSITION, SPRITE) are globally unique across worlds. Each world numbers its components independently starting at bit 0.
Single-world syntax remains unchanged. Multi-world is detected by the presence of multiple Ident { ... } blocks inside the first group.
This project is licensed under the MIT License - see the LICENSE file for details.