Entity Component System
We use EliCS, a lightweight, high-performance Entity Component System framework for TypeScript. It uses a data-oriented design that prioritizes composition over inheritance, with efficient bitmasking and typed array storage.
Core concepts: Entities (unique objects), Components (data containers), Systems (logic processors), Queries (entity selectors), and World (the container managing everything).
When to reach for it: a game with many interacting objects that share behaviors (enemies, projectiles, pickups, units) and a state-driven update loop benefits from ECS — it keeps logic in systems and state in components instead of a tangle of classes. For a tiny scene with a handful of bespoke objects, ECS is overhead; plain objects and a render loop are fine. The sections below show enough of the EliCS API to build with it; for exact signatures and the full type surface, read the installed elics types rather than relying on this page, which can lag the package.
Recommended Architecture
Use 3 layers: Input, State, and View.
- mapping Input to State (Input Systems: receive user input, normalize it, and apply it to State. Complex inputs can be split across multiple systems (e.g., raw events → normalized actions → state updates). Each input system must provide methods to imperatively invoke all inputs enabling simulating and testing user input programmatically.)
- advancing State each frame (State Systems: advance the game state every frame (e.g., enemies chasing the player).)
- synchronizing State to the View (View Systems: reflect State in the View. Subscribe during initialization to entity creation/removal when needed (e.g., to create/destroy 3D objects or UI), and update frequently changing data each frame.)
Recommended File Structure
- `src/components/*.ts`: one component per file (e.g., position.ts, velocity.ts). Components hold state only; behavior belongs in systems.
- `src/systems/*.ts`: one file per system (e.g., input.ts, physics.ts). Each system exports initialization and per-frame update functions.
- `src/seed.ts`: constructs startup entities and attaches their components so the game is immediately playable.
- `src/main.ts`: composes the game. Loads seed, registers systems, runs initialization, then advances all state each frame and syncs to the View.
Creating a World
The "world" is where all ECS elements coexist.
import { World } from 'elics'
const world = new World()
Defining Components
Components are data containers that define entity properties.
import { createComponent, Types } from 'elics'
import { world } from './world.ts'
// Available Types:
// - Scalars: Types.Int8, Types.Int16, Types.Float32, Types.Float64, Types.Boolean, Types.String
// - Vectors: Types.Vec2, Types.Vec3, Types.Vec4, Types.Color (RGBA 0-1)
// - Entity reference: Types.Entity
// - Enum: Types.Enum
// - Types.Object holds any JS value (a Three.js mesh, an array) — not an ECS reference
// Define enums for type-safe state management using const assertions
export const UnitType = {
Infantry: 'infantry',
Cavalry: 'cavalry',
Archer: 'archer',
} as const
export const CombatState = {
Idle: 'idle',
Moving: 'moving',
Attacking: 'attacking',
Defending: 'defending',
} as const
export const Position = createComponent(
'Position',
{
value: { type: Types.Vec3, default: [0, 0, 0] },
},
'3D position coordinates',
)
//register components directly after creating them
world.registerComponent(Position)
export const Velocity = createComponent(
'Velocity',
{
value: { type: Types.Vec3, default: [0, 0, 0] },
},
'3D velocity vector',
)
world.registerComponent(Velocity)
export const Health = createComponent(
'Health',
{
value: { type: Types.Float32, default: 100 },
},
'Entity health points',
)
world.registerComponent(Health)
export const Unit = createComponent(
'Unit',
{
type: { type: Types.Enum, enum: UnitType, default: UnitType.Infantry },
state: { type: Types.Enum, enum: CombatState, default: CombatState.Idle },
damage: { type: Types.Float32, default: 10, min: 1, max: 100 },
armor: { type: Types.Int16, default: 0, min: 0, max: 50 },
morale: { type: Types.Float32, default: 1.0, min: 0.0, max: 1.0 },
},
'Military unit with combat statistics',
)
world.registerComponent(Unit)
// Entity references
export const Target = createComponent('Target', {
entity: { type: Types.Entity, default: null },
})
world.registerComponent(Target)
Important: Vector types (
Vec2,Vec3,Vec4,Color) must be accessed viagetVectorView(), notgetValue()/setValue(). EliCS throws at runtime if you use the wrong accessor.
Creating Entities
Instantiate entities and attach components:
const unit = world.createEntity()
unit.addComponent(Position, { value: [10, 20, 30] })
unit.addComponent(Velocity)
unit.addComponent(Health)
unit.addComponent(Unit, {
type: UnitType.Infantry,
state: CombatState.Moving,
damage: 15,
armor: 20,
morale: 0.8,
})
// Reading and writing scalar values
const damage = unit.getValue(Unit, 'damage') // returns number
unit.setValue(Unit, 'state', CombatState.Attacking)
// Reading and writing vector values (returns TypedArray subarray)
const pos = unit.getVectorView(Position, 'value')
pos[0] = 100 // Modify X directly
// Check component existence
if (unit.hasComponent(Health)) {
/* ... */
}
// Remove component or destroy entity
unit.removeComponent(Velocity)
unit.destroy() // entity.active becomes false
Creating Systems
Systems contain the core logic.
import { createSystem, Entity } from 'elics'
import { Object3D } from 'three'
import { scene } from './scene.ts'
import { unitModel } from './unit-model.ts'
const movementQueryConfig = {
movables: { required: [Position, Velocity] },
}
export class MovementSystem extends createSystem(movementQueryConfig) {
update(delta: number, time: number) {
this.queries.movables.entities.forEach((entity: Entity) => {
const position = entity.getVectorView(Position, 'value')
const velocity = entity.getVectorView(Velocity, 'value')
position[0] += velocity[0] * delta
position[1] += velocity[1] * delta
position[2] += velocity[2] * delta
})
}
}
const unitViewQueryConfig = {
units: { required: [Unit, Position] },
}
export class UnitViewSystem extends createSystem(unitViewQueryConfig) {
public readonly models = new Map<Entity, Object3D>()
init() {
const create = (entity: Entity) => {
const newModel = unitModel.clone()
scene.add(newModel)
this.models.set(entity, newModel)
}
const destroy = (entity: Entity) => {
const oldModel = this.models.get(entity)!
scene.remove(oldModel)
this.models.delete(entity)
}
// Loop through existing and subscribe to new query entities
this.queries.units.entities.forEach(create)
this.queries.units.subscribe('qualify', create)
this.queries.units.subscribe('disqualify', destroy)
}
update(delta: number, time: number) {
this.queries.units.entities.forEach((entity) => {
const model = this.models.get(entity)!
const pos = entity.getVectorView(Position, 'value')
model.position.set(pos[0], pos[1], pos[2])
})
}
destroy() {
// Cleanup when system is unregistered
}
}
// Register with priority (lower runs first)
world.registerSystem(MovementSystem, { priority: 0 })
world.registerSystem(UnitViewSystem, { priority: 10 })
Accessing Systems
Retrieve a registered system instance using world.getSystem(...):
const enemySystem = world.getSystem(EnemySystem)!
// Access system properties and methods
enemySystem.spawnEnemy(position)
System Resource Loading
The framework calls init() (on registerSystem) and update() (each world.update). It has no load() lifecycle hook — only those two. For async resource loading, give the system your own load() method and call it yourself after registering, before the animation loop. It is an ordinary method, not a framework hook: nothing calls it unless you do.
export class EnemySystem extends createSystem(queryConfig) {
private model!: Group
init() {
/* called by registerSystem() — synchronous only */
}
async load() {
// your own method — you must await it yourself (see below)
this.model = (await new GLTFLoader().loadAsync('/models/enemy.glb')).scene
}
update(delta: number, time: number) {
/* called every frame by world.update */
}
}
In main.ts, register (which runs init()), then await your load() before the loop:
world.registerSystem(EnemySystem) // runs init()
await world.getSystem(EnemySystem)!.load()
renderer.setAnimationLoop(() => { ... })
System Lifecycle Control
Pause and resume systems dynamically using stop() and play():
const enemySystem = world.getSystem(EnemySystem)!
// Pause the system (e.g., during cutscenes or menus)
enemySystem.stop()
// Check if system is paused
if (enemySystem.isPaused) {
console.log('Enemy system is paused')
}
// Resume the system
enemySystem.play()
Example Use case: Pause physics during dialogue sequences, then resume when the player regains control.
Query Predicates
Filter entities by component values using where clauses:
const queryConfig = {
lowHealth: {
required: [Health, Unit],
excluded: [Target], // Exclude entities with this component
where: [{ component: Health, key: 'value', op: 'lt', value: 30 }],
},
}
Operators: eq (equal), ne (not equal), lt (less than), le (less or equal), gt (greater than), ge (greater or equal), in (in array), nin (not in array).
Updating the World
Update the world with delta and time.
function render() {
...
const delta = clock.getDelta()
...
world.update(delta, clock.elapsedTime)
...
}