feature-ecs is a flexible, typesafe, and performance-focused Entity Component System (ECS) library for TypeScript.
- 🔮 Simple, declarative API: Intuitive component patterns with full type safety
- 🍃 Lightweight & Tree Shakable: Function-based and modular design
- ⚡ High Performance: O(1) component checks using bitflags, cache-friendly sparse arrays
- 🔍 Powerful Querying: Query entities with complex filters and get component data efficiently
- 📦 Zero Dependencies: Standalone library ensuring ease of use in various environments
- 🔧 Flexible Storage: Supports AoS, SoA, and marker component patterns
- 🧵 Change Tracking: Built-in tracking for added, changed, and removed components
📚 Examples
🌟 Motivation
Build a modern, type-safe ECS library that fully leverages TypeScript's type system without compromising performance. While libraries like bitECS offer good speed, they often lack robust TypeScript support and more advanced queries like Added(), Removed(), or Changed(). feature-ecs bridges this gap—combining high performance, full TypeScript integration, and powerful query capabilities—all while adhering to the KISS principle for a clean, intuitive API.
⚖️ Alternatives
📖 Usage
feature-ecs offers core ECS concepts without imposing strict rules onto your architecture:
- Entities are numerical IDs representing game objects
- Components are data containers that can follow different storage patterns
- Systems are just functions that query and process entities
- Queries provide powerful filtering with change detection
For optimal performance:
Basic Setup
import { And, createWorld, With } from 'feature-ecs';
const Position = { x: [], y: [] };
const Velocity = { dx: [], dy: [] };
const Health = [];
const Player = {};
const world = createWorld();
Entity Management
const entity = world.createEntity();
world.destroyEntity(entity);
Component Operations
world.addComponent(entity, Position, { x: 100, y: 50 });
world.addComponent(entity, Velocity, { dx: 2, dy: 1 });
world.addComponent(entity, Health, 100);
world.addComponent(entity, Player, true);
world.updateComponent(entity, Position, { x: 110 });
world.updateComponent(entity, Health, 95);
world.updateComponent(entity, Player, false);
Position.x[entity] = 110;
world.markComponentChanged(entity, Position);
Health[entity] = 95;
world.markComponentChanged(entity, Health);
world.removeComponent(entity, Velocity);
if (world.hasComponent(entity, Player)) {
}
Querying
import { Added, And, Changed, Or, Removed, With, Without } from 'feature-ecs';
const players = world.queryEntities(With(Player));
const moving = world.queryEntities(And(With(Position), With(Velocity)));
const damaged = world.queryEntities(Changed(Health));
for (const [eid, pos, health] of world.queryComponents([Entity, Position, Health] as const)) {
console.log(`Entity ${eid} at (${pos.x}, ${pos.y}) with ${health} health`);
}
Game Loop
function update(deltaTime: number) {
for (const [eid, pos, vel] of world.queryComponents([Entity, Position, Velocity] as const)) {
world.updateComponent(eid, Position, {
x: pos.x + vel.dx * deltaTime,
y: pos.y + vel.dy * deltaTime
});
}
world.flush();
}
📐 Architecture
Entity Index
Efficient entity ID management using sparse-dense array pattern with optional versioning. Provides O(1) operations while maintaining cache-friendly iteration.
Sparse-Dense Pattern
Sparse Array: [_, 0, _, 2, 1, _, _] ← Maps entity ID → dense index
1 2 3 4 5 6 7 ← Entity IDs
Dense Array: [2, 5, 4, 7, 3] ← Alive entities (cache-friendly)
[0, 1, 2, 3, 4] ← Indices
└─alive─┘ └dead┘
aliveCount: 3 ← First 3 elements are alive
Core Data:
- Sparse Array: Maps base entity IDs to dense array positions
- Dense Array: Contiguous alive entities, with dead entities at end
- Alive Count: Boundary between alive/dead entities
Entity ID Format
32-bit Entity ID = [Version Bits | Entity ID Bits]
Example with 8 version bits:
┌─ Version (8 bits) ─┐┌─── Entity ID (24 bits) ───┐
00000001 000000000000000000000001
│ │
└─ Version 1 └─ Base Entity ID 1
Why This Design?
Problem: Stale References
const entity = addEntity();
removeEntity(entity);
const newEntity = addEntity();
Solution: Versioning
const entity = addEntity();
removeEntity(entity);
const newEntity = addEntity();
Swap-and-Pop for O(1) Removal
dense = [1, 2, 3, 4, 5];
Performance: O(1) all operations, ~8 bytes per entity, cache-friendly iteration.
Query System
Entity filtering with two strategies: bitmask optimization for simple queries, individual evaluation for complex queries.
Query Filters
With(Position);
Without(Dead);
Added(Position);
Changed(Health);
Removed(Velocity);
And(With(Position), With(Velocity));
Or(With(Player), With(Enemy));
Evaluation Strategies
Bitmask Strategy - Fast bitwise operations:
Position: bitflag=0b001, Velocity: bitflag=0b010, Health: bitflag=0b100
entity1: 0b011
entity2: 0b101
entity1: (0b011 & 0b011) === 0b011 ✓ true
entity2: (0b101 & 0b011) === 0b011 ✗ false
Individual Strategy - Per-filter evaluation for complex queries:
Performance (10,000 entities)
individual + cached - __tests__/query.bench.ts > Query Performance > With(Position)
1.04x faster than bitmask + cached
7.50x faster than bitmask + no cache
7.83x faster than individual + no cache
bitmask + cached - __tests__/query.bench.ts > Query Performance > And(With(Position), With(Velocity))
1.01x faster than individual + cached
13.58x faster than bitmask + no cache
13.72x faster than individual + no cache
Key Insight: Caching matters most (7-14x faster than no cache). Bitmask vs individual evaluation shows minimal difference.
Component Registry
Component management with direct array access, unlimited components via generations, and flexible storage patterns.
Component Patterns
const Position = { x: [], y: [] };
Position.x[eid] = 10;
Position.y[eid] = 20;
const Transform = [];
Transform[eid] = { x: 10, y: 20 };
const Health = [];
const Player = {};
Generation System
Unlimited components beyond 31-bit limit:
Why Generations? Bitmasks need one bit per component for fast O(1) checks. JavaScript integers are 32-bit, giving us only 31 usable bits (0 - 30, bit 31 is sign). So we can only track 31 components per bitmask.
Position: { generationId: 0, bitflag: 0b001 }
Velocity: { generationId: 0, bitflag: 0b010 }
Armor: { generationId: 1, bitflag: 0b001 }
Weapon: { generationId: 1, bitflag: 0b010 }
_entityMasks[0][eid] = 0b011;
_entityMasks[1][eid] = 0b001;
Bitmask Operations
entityMask |= 0b010;
entityMask &= ~0b010;
const hasVelocity = (entityMask & 0b010) !== 0;
Change Tracking
_addedMasks[0][eid] |= bitflag;
_changedMasks[0][eid] |= bitflag;
_removedMasks[0][eid] |= bitflag;
flush() { }
Why These Decisions?
Sparse Arrays: Memory-efficient with large entity IDs - only allocated indices use memory.
Direct Array Access: No function call overhead - Health[eid] = 100 is fastest possible.
Flexible Patterns: Physics systems benefit from SoA cache locality, UI systems need complete AoS objects.
Generations: JavaScript 32-bit integers limit us to 31 components - generations provide unlimited components.
Performance: O(1) operations, 4 bytes per entity per generation, direct memory access.
📚 Good to Know
Sparse vs Dense Arrays
JavaScript sparse arrays store only assigned indices, making them memory-efficient:
const sparse = [];
sparse[1000] = 5;
console.log(sparse.length);
console.log(sparse[500]);
In contrast, dense arrays allocate memory for every element, even if unused:
const dense = new Array(1001).fill(0);
console.log(dense.length);
console.log(dense[500]);
Use sparse arrays for large, mostly empty datasets. Use dense arrays when you need consistent iteration and performance.
💡 Resources / References
- BitECS - High-performance ECS library that inspired our implementation