ECS Architecture
ESEngine uses an Entity-Component-System (ECS) architecture for game object management. This guide explains the concepts and API.
What is ECS?
ECS separates identity (Entity), data (Component), and behavior (System):
| Concept | Description | Example |
|---|---|---|
| Entity | A unique identifier | Player, Enemy, Bullet |
| Component | Plain data struct | LocalTransform, Sprite, Velocity |
| System | Logic operating on components | MovementSystem, RenderSystem |
Entities
An entity is just a number (ID). It has no data or behavior on its own.
import { Commands, type Entity } from 'esengine';
// In a system with Commands:defineSystem([Commands()], (cmds) => { // Create a new entity const player: Entity = cmds.spawn().id();
// Despawn an entity cmds.despawn(player);});Components
Components are data attached to entities. ESEngine provides builtin components backed by C++:
Builtin Components
import { LocalTransform, Sprite, Camera, Velocity, Parent, Children } from 'esengine';
// LocalTransform - position, rotation, scalecmds.spawn().insert(LocalTransform, { position: { x: 100, y: 200, z: 0 }, rotation: { w: 1, x: 0, y: 0, z: 0 }, scale: { x: 1, y: 1, z: 1 }});
// Sprite - 2D renderingcmds.spawn().insert(Sprite, { texture: 0, color: { x: 1, y: 1, z: 1, w: 1 }, size: { x: 64, y: 64 }});
// Camera - view settingscmds.spawn().insert(Camera, { projectionType: 1, // Orthographic orthoSize: 400, isActive: true});
// Velocity - movementcmds.spawn().insert(Velocity, { linear: { x: 100, y: 0, z: 0 }, angular: { x: 0, y: 0, z: 0 }});Custom Components
You can define your own components for game-specific data:
import { defineComponent, defineTag } from 'esengine';
// Data component with default valuesconst Health = defineComponent('Health', { current: 100, max: 100});
// Tag component (no data, just for filtering)const Player = defineTag('Player');const Enemy = defineTag('Enemy');
// Usagecmds.spawn() .insert(Player) .insert(Health, { current: 100, max: 100 });Adding Components
// spawn() returns EntityCommands for chainingconst entity = cmds.spawn() .insert(LocalTransform, { position: { x: 0, y: 0, z: 0 } }) .insert(Sprite, { size: { x: 50, y: 50 } }) .insert(Health, { current: 100, max: 100 }) .id();
// Add to existing entitycmds.entity(entity) .insert(Velocity, { linear: { x: 10, y: 0, z: 0 } });Removing Components
cmds.entity(entity).remove(Velocity);Systems
Systems contain game logic. They declare what data they need via parameters.
Defining Systems
import { defineSystem, Schedule, Commands, Query, Res, Time } from 'esengine';
const movementSystem = defineSystem( [Res(Time), Query(LocalTransform, Velocity)], (time, query) => { for (const [entity, transform, velocity] of query) { transform.position.x += velocity.linear.x * time.delta; transform.position.y += velocity.linear.y * time.delta; } });
// Add to appapp.addSystemToSchedule(Schedule.Update, movementSystem);System Parameters
| Parameter | Description | Usage |
|---|---|---|
Commands() | Spawn/despawn entities | cmds.spawn() |
Query(...) | Query entities with components | for (const [e, ...] of query) |
Res(T) | Read-only resource | time.elapsed |
ResMut(T) | Mutable resource | state.score += 1 |
Schedule Types
import { Schedule } from 'esengine';
// Startup - runs once at beginningapp.addSystemToSchedule(Schedule.Startup, setupSystem);
// Update - runs every frameapp.addSystemToSchedule(Schedule.Update, gameLogicSystem);
// PostUpdate - after Updateapp.addSystemToSchedule(Schedule.PostUpdate, cleanupSystem);
// FixedUpdate - fixed timestep (physics)app.addSystemToSchedule(Schedule.FixedUpdate, physicsSystem);Queries
Queries let you iterate over entities that have specific components.
Basic Query
defineSystem( [Query(LocalTransform, Sprite)], (query) => { // Iterate all entities with both LocalTransform AND Sprite for (const [entity, transform, sprite] of query) { transform.position.x += 1; } });Query Methods
defineSystem( [Query(LocalTransform, Sprite)], (query) => { // Iterate with for-of for (const [entity, transform, sprite] of query) { ... }
// Iterate with forEach query.forEach((entity, transform, sprite) => { ... });
// Get single entity (errors if 0 or >1) const [entity, transform, sprite] = query.single();
// Get count const count = query.count(); });Resources
Resources are global singletons, not attached to entities.
Builtin Resources
import { Res, Time, Input } from 'esengine';
defineSystem( [Res(Time)], (time) => { console.log(`Elapsed: ${time.elapsed}s`); console.log(`Delta: ${time.delta}s`); });Custom Resources
import { defineResource, Res, ResMut } from 'esengine';
const GameState = defineResource({ score: 0, level: 1, paused: false});
// Read-only accessdefineSystem([Res(GameState)], (state) => { console.log(`Score: ${state.score}`);});
// Mutable accessdefineSystem([ResMut(GameState)], (state) => { state.value.score += 10;});Best Practices
Do
- Keep components small and focused
- Use tags for filtering entities (
const Player = defineTag('Player')) - Prefer composition over complex components
- Use separate systems for different concerns
Don’t
- Put methods in components (use systems instead)
- Store references to other entities directly (use entity IDs)
- Create deeply nested component data
- Modify components from multiple systems without care
Example: Complete Game Loop
import { createWebApp, defineSystem, defineComponent, defineTag, Schedule, Commands, Query, Res, Time, LocalTransform, Sprite, Camera} from 'esengine';
// Custom componentsconst Velocity = defineComponent('Velocity', { x: 0, y: 0 });const Player = defineTag('Player');
export async function main(Module) { const app = createWebApp(Module);
// Setup app.addSystemToSchedule(Schedule.Startup, defineSystem( [Commands()], (cmds) => { // Camera cmds.spawn() .insert(Camera, { projectionType: 1, orthoSize: 400, isActive: true }) .insert(LocalTransform, { position: { x: 0, y: 0, z: 10 } });
// Player cmds.spawn() .insert(Player) .insert(Sprite, { size: { x: 50, y: 50 } }) .insert(LocalTransform, { position: { x: 0, y: 0, z: 0 } }) .insert(Velocity, { x: 100, y: 50 }); } ));
// Movement app.addSystemToSchedule(Schedule.Update, defineSystem( [Res(Time), Query(LocalTransform, Velocity)], (time, query) => { for (const [entity, transform, velocity] of query) { transform.position.x += velocity.x * time.delta; transform.position.y += velocity.y * time.delta; } } ));
app.run();}