Organize related systems and resources into reusable plugins:
import ECSpresso, { definePlugin } from 'ecspresso';
interface PhysicsComponents {
position: { x: number; y: number };
velocity: { x: number; y: number };
}
interface PhysicsResources {
gravity: { value: number };
}
const physicsPlugin = definePlugin('physics')
.withComponentTypes<PhysicsComponents>()
.withResourceTypes<PhysicsResources>()
.install((world) => {
world.addSystem('applyVelocity')
.addQuery('moving', { with: ['position', 'velocity'] })
.setProcess(({ queries, dt }) => {
for (const entity of queries.moving) {
entity.components.position.x += entity.components.velocity.x * dt;
entity.components.position.y += entity.components.velocity.y * dt;
}
});
world.addSystem('applyGravity')
.addQuery('falling', { with: ['velocity'] })
.setProcess(({ queries, dt, ecs }) => {
const gravity = ecs.getResource('gravity');
for (const entity of queries.falling) {
entity.components.velocity.y += gravity.value * dt;
}
});
world.addResource('gravity', { value: 9.8 });
});
// Register plugins with the world — types merge automatically
const game = ECSpresso.create()
.withPlugin(physicsPlugin)
.build();
The install function receives a second argument, onCleanup, for registering disposers that run when the plugin is uninstalled or the world is torn down. Use it to remove event listeners, cancel timers, or release any external resources the plugin acquired.
const inputPlugin = definePlugin('input')
.install((world, onCleanup) => {
const handler = (e: KeyboardEvent) => { /* ... */ };
window.addEventListener('keydown', handler);
onCleanup(() => window.removeEventListener('keydown', handler));
const off = world.on('someEvent', () => { /* ... */ });
onCleanup(off);
});
Disposers run in reverse registration order. A failing disposer does not prevent later ones from running.
world.uninstallPlugin('input'); // runs cleanup disposers, returns true if found
world.dispose(); // uninstalls all plugins, then cleans up world state
When multiple plugins share the same types (common in application code), use pluginFactory() on the builder or built world to capture types automatically:
// types.ts — builder accumulates all types
export const builder = ECSpresso.create()
.withPlugin(createPhysicsPlugin())
.withComponentTypes<{ player: boolean; enemy: EnemyData }>()
.withResourceTypes<{ score: number }>();
// Types flow from the builder — no manual imports or extends chains
export const definePlugin = builder.pluginFactory();
// movement-plugin.ts — no type params needed
import { definePlugin } from './types';
export const movementPlugin = definePlugin({
id: 'movement',
install(world) {
world.addSystem('movement')
.addQuery('moving', { with: ['position', 'velocity'] })
.setProcess(({ queries, dt }) => { /* ... */ });
},
});
Use slot-specific config helpers when a plugin requires existing world types but does not provide them itself. This avoids spelling empty WorldConfigFrom slots just to reach resources, assets, events, or screens.
import {
definePlugin,
type ComponentsConfig,
type ResourcesConfig,
type ScreensConfig,
type ScreenDefinition,
} from 'ecspresso';
type TransformComponents = {
worldTransform: { x: number; y: number };
};
type InputResources = {
input: { isPressed(action: string): boolean };
};
type RequiredScreens = {
playing: ScreenDefinition<{ level: number }>;
};
type MovementRequires =
ComponentsConfig<TransformComponents>
& ResourcesConfig<InputResources>
& ScreensConfig<RequiredScreens>;
export const movementPlugin = definePlugin('movement')
.requires<MovementRequires>()
.install((world) => {
world.addSystem('movement')
.inScreens(['playing'])
.setProcess(({ ecs }) => {
const input = ecs.getResource('input');
if (!input.isPressed('right')) return;
});
});
Available helpers are ComponentsConfig<T>, EventsConfig<T>, ResourcesConfig<T>, AssetsConfig<T>, and ScreensConfig<T>. Use WorldConfigFrom directly when a type naturally spans several slots and the positional form is still clearer.
Plugins can declare that certain components depend on others. When an entity gains a trigger component, any required components that aren't already present are auto-added with default values:
const transformPlugin = definePlugin('transform')
.withComponentTypes<TransformComponents>()
.install((world) => {
world.registerRequired('localTransform', 'worldTransform', () => ({
x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1,
}));
});
const world = ECSpresso.create()
.withPlugin(transformPlugin)
.build();
// worldTransform is auto-added with defaults
const entity = world.spawn({
localTransform: { x: 100, y: 200, rotation: 0, scaleX: 1, scaleY: 1 },
});
// Explicit values always win — no auto-add if already provided
const entity2 = world.spawn({
localTransform: { x: 100, y: 200, rotation: 0, scaleX: 1, scaleY: 1 },
worldTransform: { x: 50, y: 50, rotation: 0, scaleX: 2, scaleY: 2 }, // used as-is
});
Requirements can also be registered via the builder or at runtime:
// Builder
const world = ECSpresso.create()
.withComponentTypes<Components>()
.withRequired('rigidBody', 'velocity', () => ({ x: 0, y: 0 }))
.withRequired('rigidBody', 'force', () => ({ x: 0, y: 0 }))
.build();
// Runtime
world.registerRequired('position', 'velocity', () => ({ x: 0, y: 0 }));
spawn, addComponent, addComponents, spawnChild, command buffer)The Transform plugin registers localTransform → worldTransform. The Physics 2D plugin registers rigidBody → velocity and rigidBody → force.