ecspresso
Version:
A minimal Entity-Component-System library for typescript and javascript.
493 lines (397 loc) • 15 kB
Markdown
# ECSpresso
*(pronounced "ex-presso")*
__Note: This is a VERY early work in progress. No work on performance has been done while the API is being nailed down. The documention is also being autogenerated while ECSpresso is being iterated on.__
A type-safe, modular, and extensible Entity Component System (ECS) framework for TypeScript.
## Features
- 🔒 **Type-Safe**: Full TypeScript support with type inference for components, events, and resources
- 🧩 **Modular**: Bundle-based architecture for modular gameplay systems and features
- 💡 **Flexible**: Easily create entities, add components, and build systems with a clean, fluent API
- 🔄 **Event-Driven**: Integrated event bus for communication between systems
- 🗄️ **Resource Management**: Global resources for sharing state across systems
- ⏱️ **Priority Control**: Set execution priority for systems to ensure proper processing order
## Installation
```sh
npm install ecspresso
```
## Quick Start
```typescript
import { ECSpresso } from 'ecspresso';
// Define your component types
interface Components {
position: { x: number; y: number };
velocity: { x: number; y: number };
sprite: { url: string };
}
// Define your event types
interface Events {
collision: { entity1: number; entity2: number };
scoreChange: { amount: number };
}
// Define your resource types
interface Resources {
score: { value: number };
gameState: 'playing' | 'paused' | 'gameOver';
assets: { textures: Record<string, any>; sounds: Record<string, any> };
}
// Create a world instance directly
const world = new ECSpresso<Components, Events, Resources>();
// Add resources - can be direct values or factory functions
world.addResource('score', { value: 0 });
world.addResource('gameState', 'paused');
world.addResource('assets', async () => {
// Simulate loading game assets
const textures = await loadTextures();
const sounds = await loadSounds();
return { textures, sounds };
});
// Add a movement system directly to the world
world.addSystem('movement')
.addQuery('movingEntities', {
with: ['position', 'velocity']
})
.setProcess((queries, deltaTime) => {
for (const entity of queries.movingEntities) {
entity.components.position.x += entity.components.velocity.x * deltaTime;
entity.components.position.y += entity.components.velocity.y * deltaTime;
}
})
.setOnInitialize(async (ecs) => {
// One-time initialization of the system
console.log('Setting up movement system...');
const gameState = ecs.getResource('gameState');
gameState.lastMovementUpdate = Date.now();
})
.build(); // Don't forget to call build() to finalize the system
// Create an entity with position and velocity components
const entity = world.entityManager.createEntity();
world.entityManager.addComponent(entity.id, 'position', { x: 0, y: 0 });
world.entityManager.addComponent(entity.id, 'velocity', { x: 10, y: 5 });
// Initialize everything (resources and systems) in one call
await world.initialize();
// Run a single update
world.update(1/60);
// Check new position
const position = world.entityManager.getComponent(entity.id, 'position');
console.log(position); // { x: 0.16666..., y: 0.08333... }
```
## Building Modular Systems with Bundles
Bundles are a powerful way to organize game features:
```typescript
// Create a player input bundle
const inputBundle = new Bundle<Components, Events, Resources>('input-bundle')
.addSystem('playerInput')
.setProcess((_queries, _deltaTime, ecs) => {
// Handle keyboard input and modify player velocity
// ...
});
// Create a rendering bundle
const renderBundle = new Bundle<Components, Events, Resources>('render-bundle')
.addSystem('renderer')
.addQuery('sprites', { with: ['position', 'sprite'] })
.setProcess((queries) => {
// Render all sprites
for (const entity of queries.sprites) {
// Draw entities at their positions
// ...
}
});
// Create a scoring bundle that adds a resource and listens for events
const scoringBundle = new Bundle<Components, Events, Resources>('scoring-bundle')
.addResource('score', { value: 0 })
// Resources can also be added using factory functions
.addResource('gameStats', () => ({
highScore: 0,
totalPlayTime: 0,
sessionStartTime: Date.now()
}))
.addSystem('scoreKeeper')
.setEventHandlers({
scoreChange: {
handler: (data, ecs) => {
const score = ecs.getResource('score');
score.value += data.amount;
console.log(`Score: ${score.value}`);
}
}
});
// Create a game initialization bundle with event handlers
const initBundle = new Bundle<Components, Events, Resources>('init-bundle')
.addSystem('initialization')
.setOnInitialize(async (ecs) => {
console.log('Game systems initializing...');
// Do one-time system setup here
})
.setEventHandlers({
gameStart: {
async handler(data, ecs) {
console.log('Game starting...');
// Initialize all resources and systems
await ecs.initialize();
// Resources and systems are now ready to use
const assets = ecs.getResource('assets');
console.log(`Loaded ${Object.keys(assets.textures).length} textures`);
// Continue with game initialization
// ...
}
}
});
// Create the game world with all features using the builder pattern
const game = ECSpresso.create<Components, Events, Resources>()
.withBundle(initBundle)
.withBundle(inputBundle)
.withBundle(renderBundle)
.withBundle(scoringBundle)
.build()
.addResource('assets', async () => {
// This won't execute until initializeResources is called
return { textures: await loadTextures(), sounds: await loadSounds() };
});
// Start the game
game.eventBus.publish('gameStart', {});
```
## Type Safety with the Builder Pattern
ECSpresso uses a builder pattern to provide strong type checking for bundle compatibility:
```typescript
// These bundles have compatible component types
const bundle1 = new Bundle<{position: {x: number, y: number}}>('bundle1');
const bundle2 = new Bundle<{velocity: {x: number, y: number}}>('bundle2');
// Create a world with both bundles - TypeScript will allow this
const world = ECSpresso.create()
.withBundle(bundle1)
.withBundle(bundle2)
.build();
// These bundles have conflicting component types
const bundle3 = new Bundle<{position: {x: number, y: number}}>('bundle3');
const bundle4 = new Bundle<{position: string}>('bundle4');
// TypeScript will show an error because bundles have conflicting types
const world2 = ECSpresso.create()
.withBundle(bundle3)
// @ts-expect-error - TypeScript will flag this because the position types conflict
.withBundle(bundle4)
.build();
```
## Working with Entities and Components
```typescript
const world = ECSpresso.create<Components, Events, Resources>()
.withBundle(/* your bundle */)
.build();
// Create an entity
const entity = world.entityManager.createEntity();
// Add components individually
world.entityManager.addComponent(entity.id, 'position', { x: 0, y: 0 });
world.entityManager.addComponent(entity.id, 'velocity', { x: 0, y: 0 });
// Add multiple components at once
world.entityManager.addComponents(entity, {
position: { x: 10, y: 20 },
velocity: { x: 5, y: -2 }
});
// Get component data
const position = world.entityManager.getComponent(entity.id, 'position');
// Check if an entity has a component
const hasPosition = world.entityManager.hasComponent(entity.id, 'position');
// Remove a component
world.entityManager.removeComponent(entity.id, 'velocity');
// Remove an entity (and all its components)
world.entityManager.removeEntity(entity.id);
```
## Working with Systems and Queries
Systems can be added directly to an ECSpresso instance:
```typescript
const world = ECSpresso.create<Components, Events, Resources>()
.build();
world.addSystem('physicsSystem')
// Set system execution priority (higher numbers execute first)
.setPriority(50)
// Query entities that have both position and velocity components
.addQuery('movingEntities', {
with: ['position', 'velocity']
})
// Query entities that have position but not player component
.addQuery('nonPlayerObjects', {
with: ['position'],
without: ['player']
})
// Query entities with different component combinations
.addQuery('flyingNonPlayerEntities', {
with: ['flying', 'position'],
without: ['player', 'grounded']
})
.setProcess((queries, deltaTime) => {
// Process moving entities
for (const entity of queries.movingEntities) {
entity.components.position.x += entity.components.velocity.x * deltaTime;
entity.components.position.y += entity.components.velocity.y * deltaTime;
}
// Process non-player objects
for (const entity of queries.nonPlayerObjects) {
// Do something with non-player objects
}
// Process flying non-player entities
for (const entity of queries.flyingNonPlayerEntities) {
// Apply flying behavior
}
})
.build(); // Finalizes and adds the system to the world
```
## System Lifecycle Hook
ECSpresso systems have two lifecycle hooks that you can implement:
```typescript
// Add a system with all lifecycle hooks
world.addSystem('gameSystem')
// Called during the ECSpresso.initialize() method
// Good for one-time setup that depends on resources
.setOnInitialize(async (ecs) => {
console.log('System initializing');
// Load resources, set up game state, etc.
// Can be async and await other operations
await loadLevelData(ecs);
})
// Called when the system is removed from the ECSpresso instance
.setOnDetach((ecs) => {
console.log('System detached');
// Clean up resources, cancel subscriptions, etc.
})
.build();
```
The `initialize` method on the ECSpresso instance initializes all resources and systems:
```typescript
await ecs.initialize();
```
## System Priority
ECSpresso allows you to control the execution order of systems using priorities:
```typescript
// Systems with higher priority values execute before those with lower values
// Default priority is 0 if not specified
// Rendering system (runs first)
world.addSystem('renderSystem')
.setPriority(100)
.setProcess(() => {
// Rendering logic
})
.build();
// Physics system (runs second)
world.addSystem('physicsSystem')
.setPriority(50)
.setProcess(() => {
// Physics update logic
})
.build();
// Cleanup system (runs last)
world.addSystem('cleanupSystem')
.setPriority(0) // Default priority if not specified
.setProcess(() => {
// Cleanup logic
})
.build();
```
Systems with the same priority value execute in the order they were registered, maintaining backward compatibility with existing code.
You can also update a system's priority dynamically at runtime:
```typescript
// Change a system's priority (higher numbers execute first)
world.updateSystemPriority('physicsSystem', 110); // Now physics will run before rendering
```
Priority also works with systems added through bundles:
```typescript
const highPriorityBundle = new Bundle<Components>()
.addSystem('importantSystem')
.setPriority(100)
.setProcess(() => {
// This will run first
});
const lowPriorityBundle = new Bundle<Components>()
.addSystem('lateSystem')
.setPriority(0)
.setProcess(() => {
// This will run last
});
const world = ECSpresso.create<Components>()
.withBundle(lowPriorityBundle) // Added first but runs last due to priority
.withBundle(highPriorityBundle) // Added second but runs first due to priority
.build();
```
The system priority implementation is optimized with a cached sorting mechanism that only re-sorts systems when priorities change or when systems are added or removed, avoiding unnecessary sorting during each update cycle.
## Event System
The event system allows communication between systems:
```typescript
// Define an event handler in a system
const collisionBundle = new Bundle<Components, Events, Resources>('collision-bundle')
.addSystem('collisionResponse')
.setEventHandlers({
collision: {
handler: (data, ecs) => {
// Handle collision event
// data contains entity1 and entity2 from the event
}
}
});
const world = ECSpresso.create<Components, Events, Resources>()
.withBundle(collisionBundle)
.build();
// Publish an event from anywhere
world.eventBus.publish('collision', {
entity1: 1,
entity2: 2
});
// Subscribe to events manually (outside of systems)
const unsubscribe = world.eventBus.subscribe('collision', (data) => {
console.log(`Collision between entities ${data.entity1} and ${data.entity2}`);
});
// Stop listening
unsubscribe();
```
## Resources
Resources provide global state accessible to all systems:
```typescript
// Add a resource directly
world.addResource('score', { value: 0 });
// Get a resource
const score = world.getResource('score');
score.value += 10;
// Check if a resource exists
const hasScore = world.hasResource('score');
```
### Resource Factory Functions
Resources can also be created using factory functions, which is useful for lazy initialization or async resources:
```typescript
// Add a resource using a synchronous factory function
world.addResource('controlMap', () => {
console.log('Creating control map');
return {
up: false,
down: false,
left: false,
right: false
};
});
// Add a resource using a factory function that receives the ECSpresso instance
world.addResource('playerConfig', (ecs) => {
// Access other resources during initialization
const gameConfig = ecs.getResource('gameConfig');
return {
speed: gameConfig.difficulty === 'hard' ? 200 : 100,
startingHealth: gameConfig.difficulty === 'hard' ? 50 : 100
};
});
// Add a resource using an asynchronous factory function
world.addResource('gameAssets', async (ecs) => {
console.log('Loading game assets...');
// You can access other resources during async initialization
const settings = ecs.getResource('settings');
const assets = await loadAssets(settings.assetQuality);
return assets;
});
// Factory functions are executed when the resource is first accessed
const controlMap = world.getResource('controlMap'); // Factory executes here
// Or when explicitly initialized
await world.initializeResources(); // Initializes all pending resources
// You can also initialize specific resources
await world.initializeResources('gameAssets', 'controlMap');
```
Factory functions are useful for:
- Lazy loading of expensive resources
- Async initialization of resources requiring network or file operations
- Resources that depend on other systems being initialized first
- Avoiding circular dependencies in your initialization code
When using async factory functions, ensure you either:
1. Call `initializeResources()` explicitly before accessing the resource, or
2. Use `await` when getting a resource that might return a Promise