This guide provides a deep dive into the architecture and design principles of PECS (Persistent Entity Component System).
- What is an ECS?
- PECS Architecture
- Dual ID System
- Archetype-Based Storage
- Command Buffers
- Persistence System
- Design Philosophy
Entity Component System (ECS) is an architectural pattern commonly used in game development and data-oriented applications. It separates:
- Entities: Unique identifiers (just IDs)
- Components: Pure data (no behavior)
- Systems: Logic that operates on components
Traditional Object-Oriented:
// ❌ Tight coupling, inheritance hierarchies
class GameObject {
position: Position,
render() { ... },
update() { ... }
}
class Enemy extends GameObject {
ai: AI,
attack() { ... }
}ECS Approach:
// ✅ Composition over inheritance, data-oriented
struct Position { x: f32, y: f32 }
struct Velocity { x: f32, y: f32 }
struct Enemy { aggression: f32 }
// Systems operate on components
fn movement_system(world: &mut World) {
for (pos, vel) in world.query::<(&mut Position, &Velocity)>() {
pos.x += vel.x;
pos.y += vel.y;
}
}- Performance: Cache-friendly data layout
- Flexibility: Easy to add/remove behaviors
- Parallelization: Systems can run in parallel
- Composition: Build complex entities from simple components
- Data-Oriented: Optimized for modern CPU architectures
PECS is designed as a library, not a framework. You integrate it into your application rather than building your application around it.
pecs/
├── entity/ # Entity lifecycle management
│ ├── EntityId # Fast runtime IDs
│ ├── StableId # Persistent UUIDs
│ └── EntityManager
├── component/ # Component storage
│ ├── Component # Component trait
│ ├── Archetype # Storage optimization
│ └── ComponentSet
├── query/ # Data access (Phase 3)
│ ├── Query # Type-safe queries
│ ├── Fetch # Component fetching
│ └── Filter # Entity filtering
├── command/ # Deferred operations
│ ├── Command # Command trait
│ └── CommandBuffer
├── persistence/ # Save/load system
│ ├── Plugin # Pluggable formats
│ ├── Binary # Binary serialization
│ └── JSON # JSON serialization
└── world/ # Main coordinator
└── World # Central hub
┌─────────────────────────────────────────────┐
│ World │
│ ┌────────────┐ ┌──────────────────────┐ │
│ │ Entities │ │ Components │ │
│ │ │ │ ┌────────────────┐ │ │
│ │ EntityId │──┼─▶│ Archetype 1 │ │ │
│ │ StableId │ │ │ [Pos, Vel] │ │ │
│ │ │ │ └────────────────┘ │ │
│ │ Manager │ │ ┌────────────────┐ │ │
│ │ │ │ │ Archetype 2 │ │ │
│ └────────────┘ │ │ [Pos, Health] │ │ │
│ │ └────────────────┘ │ │
│ ┌────────────┐ └──────────────────────┘ │
│ │ Commands │ │
│ │ Buffer │ Deferred Operations │
│ └────────────┘ │
└─────────────────────────────────────────────┘
PECS uses two types of entity identifiers to balance performance and persistence needs.
Purpose: Fast runtime operations
Structure: 64-bit packed integer
┌──────────────────┬──────────────────┐
│ Generation │ Index │
│ (32 bits) │ (32 bits) │
└──────────────────┴──────────────────┘
Properties:
- Size: 8 bytes
- Performance: O(1) lookup, ~5ns access
- Recycling: Generation counter prevents use-after-free
- Scope: Valid only within current session
Example:
use pecs::entity::EntityId;
let id = EntityId::new(42, 1);
println!("Index: {}", id.index()); // 42
println!("Generation: {}", id.generation()); // 1
println!("Display: {}", id); // "42v1"Purpose: Cross-session persistence
Structure: 128-bit UUID
┌──────────────────────────────────────┐
│ 128-bit UUID │
│ (High 64 bits) │ (Low 64 bits) │
└──────────────────────────────────────┘
Properties:
- Size: 16 bytes
- Uniqueness: Globally unique (UUID v4)
- Performance: ~100ns generation
- Scope: Persistent across sessions
Example:
use pecs::entity::StableId;
let stable = StableId::new();
println!("Stable ID: {}", stable);
// Output: "a1b2c3d4e5f6789012345678abcdef01"| Aspect | EntityId | StableId |
|---|---|---|
| Speed | ✅ Very fast | |
| Size | ✅ 8 bytes | |
| Persistence | ❌ Session-only | ✅ Permanent |
| Uniqueness | ✅ Global | |
| Use Case | Runtime queries | Save/load |
Best Practice: Use EntityId for all runtime operations, StableId only for persistence.
PECS maintains bidirectional mapping:
let mut world = World::new();
let entity = world.spawn_empty();
// EntityId → StableId
let stable = world.get_stable_id(entity).unwrap();
// StableId → EntityId
let found = world.get_entity_id(stable).unwrap();
assert_eq!(found, entity);When an entity is despawned, its slot can be reused:
let e1 = world.spawn_empty();
println!("Entity 1: {}", e1); // "0v1"
world.despawn(e1);
let e2 = world.spawn_empty();
println!("Entity 2: {}", e2); // "0v2" (same index, new generation)
// Old reference is now invalid
assert!(!world.is_alive(e1));
assert!(world.is_alive(e2));Key Point: The generation counter prevents accidental use of stale entity references.
PECS uses an archetype-based storage system for optimal performance.
An archetype is a unique combination of component types. All entities with the same components belong to the same archetype.
// Archetype 1: [Position, Velocity]
entity_1: Position { x: 0.0, y: 0.0 }, Velocity { x: 1.0, y: 0.0 }
entity_2: Position { x: 5.0, y: 3.0 }, Velocity { x: 0.5, y: 0.5 }
// Archetype 2: [Position, Health]
entity_3: Position { x: 10.0, y: 10.0 }, Health { current: 100, max: 100 }
// Archetype 3: [Position, Velocity, Health]
entity_4: Position { x: 2.0, y: 2.0 }, Velocity { x: 1.0, y: 1.0 }, Health { current: 50, max: 100 }Components are stored in Structure of Arrays (SoA) format:
Archetype [Position, Velocity]:
┌─────────────────────────────────────┐
│ Entities: [e1, e2, e3, ...] │
├─────────────────────────────────────┤
│ Position: [p1, p2, p3, ...] │
│ Contiguous memory │
├─────────────────────────────────────┤
│ Velocity: [v1, v2, v3, ...] │
│ Contiguous memory │
└─────────────────────────────────────┘
- Cache Locality: Components of the same type are stored contiguously
- Fast Iteration: Queries iterate over dense arrays
- Memory Efficiency: No padding between components
- SIMD Friendly: Contiguous data enables vectorization
When components are added/removed, entities move between archetypes:
// Entity starts in archetype [Position]
let entity = world.spawn()
.with(Position { x: 0.0, y: 0.0 })
.id();
// Adding Velocity moves to archetype [Position, Velocity]
world.insert(entity, Velocity { x: 1.0, y: 0.0 });
// Removing Position moves to archetype [Velocity]
world.remove::<Position>(entity);Performance Note: Archetype transitions involve copying data, so minimize component add/remove operations in hot paths.
PECS caches archetype transitions for performance:
Archetype [Position]
├─ +Velocity → Archetype [Position, Velocity]
├─ +Health → Archetype [Position, Health]
└─ -Position → Archetype []
Archetype [Position, Velocity]
├─ +Health → Archetype [Position, Velocity, Health]
├─ -Position → Archetype [Velocity]
└─ -Velocity → Archetype [Position]
Command buffers enable thread-safe, deferred operations on the world.
Problem: Direct world mutation isn't thread-safe
// ❌ Can't do this from multiple threads
world.spawn(); // Requires &mut WorldSolution: Record commands, apply later
// ✅ Can do this from multiple threads
let mut buffer = CommandBuffer::new();
buffer.spawn(); // Just records the command
buffer.apply(&mut world); // Apply when safeuse pecs::command::CommandBuffer;
let mut buffer = CommandBuffer::new();
// Record operations
buffer.spawn();
buffer.spawn();
let entity = buffer.spawn();
buffer.despawn(entity);
// Nothing has happened yet
assert_eq!(world.len(), 0);
// Apply all commands atomically
buffer.apply(&mut world);
assert_eq!(world.len(), 2);Command buffers are Send but not Sync:
use std::thread;
let mut world = World::new();
// Each thread gets its own buffer
let handle = thread::spawn(|| {
let mut buffer = CommandBuffer::new();
for _ in 0..100 {
buffer.spawn();
}
buffer // Return buffer to main thread
});
let buffer = handle.join().unwrap();
buffer.apply(&mut world);- Parallel Systems: Record changes from multiple threads
- Deferred Deletion: Mark entities for deletion without immediate removal
- Batch Operations: Group operations for better performance
- Event Handling: Queue entity spawns from events
PECS provides a pluggable persistence system for saving and loading worlds.
┌──────────────────────────────────────┐
│ PersistenceManager │
│ ┌────────────────────────────────┐ │
│ │ Registered Plugins │ │
│ │ ├─ BinaryPlugin (default) │ │
│ │ ├─ JsonPlugin │ │
│ │ └─ CustomPlugin │ │
│ └────────────────────────────────┘ │
│ ┌────────────────────────────────┐ │
│ │ WorldMetadata │ │
│ │ ├─ Version │ │
│ │ ├─ Timestamp │ │
│ │ └─ Component Registry │ │
│ └────────────────────────────────┘ │
│ ┌────────────────────────────────┐ │
│ │ ChangeTracker │ │
│ │ ├─ Created entities │ │
│ │ ├─ Deleted entities │ │
│ │ └─ Modified components │ │
│ └────────────────────────────────┘ │
└──────────────────────────────────────┘
Binary Format (default):
- Compact size
- Fast serialization
- Version-aware
- Checksum validation
JSON Format:
- Human-readable
- Easy debugging
- Cross-platform
- Larger file size
use pecs::World;
// Save world
let world = World::new();
world.save("world.pecs")?;
// Load world
let loaded = World::load("world.pecs")?;For better performance with large worlds:
use std::fs::File;
// Save to stream
let mut file = File::create("world.pecs")?;
world.save_binary(&mut file)?;
// Load from stream
let mut file = File::open("world.pecs")?;
let world = World::load_binary(&mut file)?;Mark components as transient (not saved):
use pecs::persistence::SerializableComponent;
#[derive(Debug)]
struct CachedData {
// Runtime-only data
}
impl Component for CachedData {}
// Don't implement SerializableComponent
// This component won't be savedPECS follows several key design principles:
Philosophy: Integrate PECS into your application, don't build around it.
// ✅ PECS way: You control the structure
fn main() {
let mut world = World::new();
let mut game = MyGame::new();
loop {
game.update(&mut world);
game.render(&world);
}
}
// ❌ Framework way: Framework controls structure
// fn main() {
// App::new()
// .add_system(my_system)
// .run();
// }- Zero-cost abstractions where possible
- Cache-friendly data layouts
- Minimal allocations
- SIMD-friendly operations
- Compile-time query validation
- No runtime type errors
- Rust's ownership system prevents data races
- Pluggable persistence
- Custom components
- No forced patterns
- Minimal dependencies
- Small, focused API
- Clear documentation
- Predictable behavior
- Easy to learn
| Operation | Complexity | Typical Time |
|---|---|---|
| Spawn | O(1) amortized | ~100-300ns |
| Despawn | O(1) | ~50ns |
| Is Alive | O(1) | ~5ns |
| Get Stable ID | O(1) | ~10ns |
| Operation | Complexity | Typical Time |
|---|---|---|
| Insert | O(1)* | ~100ns |
| Remove | O(1)* | ~100ns |
| Get | O(1) | ~5ns |
| Get Mut | O(1) | ~5ns |
*May trigger archetype transition (O(n) where n = component count)
| Operation | Complexity | Typical Time |
|---|---|---|
| Iteration | O(n) | ~10-20ns per entity |
| Archetype Match | O(1) | ~5ns |
| Filter | O(n) | ~15-25ns per entity |
| Operation | Complexity | Typical Time |
|---|---|---|
| Binary Save | O(n) | ~0.36ms per 1000 entities |
| Binary Load | O(n) | ~0.28ms per 1000 entities |
| JSON Save | O(n) | ~2-3ms per 1000 entities |
| JSON Load | O(n) | ~3-4ms per 1000 entities |
EntityAllocator:
├─ Entities: Vec<EntityMeta> [8 bytes per entity]
├─ Free List: Vec<u32> [4 bytes per free slot]
├─ Stable IDs: HashMap [~40 bytes per entity]
└─ Reverse Map: HashMap [~40 bytes per entity]
Total: ~96 bytes per entity (worst case)
Archetype:
├─ Entities: Vec<EntityId> [8 bytes per entity]
├─ Components: Vec<Vec<u8>> [size_of::<T>() per entity]
└─ Metadata: ComponentInfo [~64 bytes per component type]
Total: 8 + Σ(component_sizes) bytes per entity
- Getting Started - Basic usage tutorial
- Performance Guide - Optimization techniques
- Advanced Patterns - Expert-level usage
- API Reference - Complete API documentation
- ADRs - Architecture Decision Records