A fast, minimal archetype-based Entity Component System written in TypeScript.
- Structure-of-Arrays (SoA) — each component field is a contiguous typed array column (
Float64Array,Int32Array, etc.), enabling cache-friendly inner loops. - Phantom-typed components —
ComponentDef<{x:"f64",y:"f64"}>is just a number at runtime, but enforces field names and types at compile time. - Batch iteration —
for..ofover a query yields non-empty archetypes. Access SoA columns viaget_column()and write the inner loop. - Single-entity refs —
ctx.ref(Pos, entity)gives you a cached accessor with prototype-backed getters/setters. - Resources — typed global singletons (time, input, config) with live readers.
- Events & signals — fire-and-forget SoA channels, auto-cleared each frame.
- Deferred structural changes — add/remove component and destroy entity are buffered during system execution and flushed between phases.
- Topological system ordering — systems within a phase are sorted by before/after constraints using Kahn's algorithm.
- Fixed timestep — configurable fixed update loop with accumulator and interpolation alpha.
Using npm (or equivalent):
npm install @oasys/oecs
import { ECS, SCHEDULE } from "@oasys/oecs";
const world = new ECS();
// Record syntax — per-field type control
const Pos = world.register_component({ x: "f64", y: "f64" });
// Array shorthand — uniform type, defaults to "f64"
const Vel = world.register_component(["vx", "vy"] as const);
// Tags have no fields
const IsEnemy = world.register_tag();
// Resources are global singletons
const Time = world.register_resource(["delta", "elapsed"] as const, {
delta: 0,
elapsed: 0,
});
// Events are fire-and-forget messages
const Damage = world.register_event(["target", "amount"] as const);
// Create entities and attach components
const e = world.create_entity();
world.add_component(e, Pos, { x: 0, y: 0 });
world.add_component(e, Vel, { vx: 100, vy: 50 });
world.add_component(e, IsEnemy);
// Register a system with a typed query
const moveSys = world.register_system(
(q, _ctx, dt) => {
for (const arch of q) {
const px = arch.get_column(Pos, "x");
const py = arch.get_column(Pos, "y");
const vx = arch.get_column(Vel, "vx");
const vy = arch.get_column(Vel, "vy");
const n = arch.entity_count;
for (let i = 0; i < n; i++) {
px[i] += vx[i] * dt;
py[i] += vy[i] * dt;
}
}
},
(qb) => qb.every(Pos, Vel),
);
// Schedule the system
world.add_systems(SCHEDULE.UPDATE, moveSys);
// Initialize
world.startup();
// Game loop
function frame(dt: number) {
world.set_resource(Time, { delta: dt, elapsed: performance.now() / 1000 });
world.update(dt);
}ECS accepts an optional configuration object:
const world = new ECS({
initial_capacity: 4096, // pre-allocate archetype storage (default: 1024, grows automatically)
fixed_timestep: 1 / 50, // fixed update interval in seconds (default: 1/60)
max_fixed_steps: 4, // cap fixed updates per frame to prevent spiral of death (default: 5)
});| Option | Type | Default | Description |
|---|---|---|---|
initial_capacity |
number |
1024 |
Starting size for every archetype's backing typed arrays (entity IDs and all SoA component columns). Arrays double when exceeded — set this close to your expected entity count per archetype to avoid early re-allocations. |
fixed_timestep |
number |
1/60 |
Interval (seconds) for FIXED_UPDATE systems. |
max_fixed_steps |
number |
5 |
Maximum FIXED_UPDATE iterations per frame. |
Components map field names to typed array tags. All field values are number, but storage uses the specified typed array (Float64Array, Int32Array, etc.) for cache-friendly iteration.
// Record syntax — per-field type control
const Position = world.register_component({ x: "f64", y: "f64" });
const Health = world.register_component({ current: "i32", max: "i32" });
// Array shorthand — all fields default to "f64"
const Vel = world.register_component(["vx", "vy"] as const);
// Tags — no fields
const IsEnemy = world.register_tag();Supported typed array tags: "f32", "f64", "i8", "i16", "i32", "u8", "u16", "u32".
Add components individually or via add_components (single archetype transition):
world.add_component(e, Position, { x: 10, y: 20 });
world.add_components(e, [
{ def: Position, values: { x: 10, y: 20 } },
{ def: Health, values: { current: 100, max: 100 } },
]);See docs/api/components.md for full API.
Queries are live views over all archetypes matching a component mask.
const q = world.query(Position, Velocity);
// Iterate non-empty archetypes, access SoA columns, write the inner loop
for (const arch of q) {
const px = arch.get_column(Position, "x");
const py = arch.get_column(Position, "y");
const vx = arch.get_column(Velocity, "vx");
const vy = arch.get_column(Velocity, "vy");
const n = arch.entity_count;
for (let i = 0; i < n; i++) {
px[i] += vx[i];
py[i] += vy[i];
}
}
// Chaining
const targets = world
.query(Position)
.and(Health)
.not(Shield)
.any_of(IsEnemy, IsBoss);See docs/api/queries.md for full API.
Systems are plain functions registered with a query and scheduled into lifecycle phases.
// With a typed query
const moveSys = world.register_system(
(q, ctx, dt) => {
for (const arch of q) {
/* ... */
}
},
(qb) => qb.every(Pos, Vel),
);
// Without a query
const logSys = world.register_system({
fn(ctx, dt) {
console.log("frame", dt);
},
});Inside systems, use ctx for deferred structural changes and per-entity access:
const e = ctx.create_entity();
ctx.add_component(e, Pos, { x: 0, y: 0 });
ctx.destroy_entity(someEntity);
ctx.remove_component(entity, Health);See docs/api/systems.md for full API.
Resources are typed global singletons — time, input state, camera config.
const Time = world.register_resource(["delta", "elapsed"] as const, {
delta: 0,
elapsed: 0,
});
// Write
world.set_resource(Time, { delta: dt, elapsed: total });
// Read — scalar values, not arrays
const time = world.resource(Time);
time.delta; // number
time.elapsed; // numberSee docs/api/resources.md for full API.
Events are fire-and-forget SoA channels, auto-cleared each frame.
// Data events carry fields
const Damage = world.register_event(["target", "amount"] as const);
ctx.emit(Damage, { target: entityId, amount: 50 });
const dmg = ctx.read(Damage);
for (let i = 0; i < dmg.length; i++) {
dmg.target[i]; // number
dmg.amount[i]; // number
}
// Signals carry no data — just a count
const OnReset = world.register_signal();
ctx.emit(OnReset);
if (ctx.read(OnReset).length > 0) {
/* fired */
}See docs/api/events.md for full API.
Refs provide cached single-entity field access — faster than get_field/set_field for repeated access.
const pos = ctx.ref(Pos, entity);
const vel = ctx.ref(Vel, entity);
pos.x += vel.vx * dt;
pos.y += vel.vy * dt;See docs/api/refs.md for full API.
Seven lifecycle phases, executed in order:
| Phase | When | Use case |
|---|---|---|
PRE_STARTUP |
Once, before startup | Resource loading |
STARTUP |
Once | Initial entity spawning |
POST_STARTUP |
Once, after startup | Validation |
FIXED_UPDATE |
Every tick (fixed dt) | Physics, simulation |
PRE_UPDATE |
Every frame, first | Input handling |
UPDATE |
Every frame | Game logic |
POST_UPDATE |
Every frame, last | Rendering, cleanup |
world.add_systems(SCHEDULE.UPDATE, moveSys, physicsSys);
world.add_systems(SCHEDULE.POST_UPDATE, renderSys);
// Ordering constraints
world.add_systems(SCHEDULE.UPDATE, moveSys, {
system: physicsSys,
ordering: { after: [moveSys] },
});See docs/api/schedule.md for full API.
const e = world.create_entity();
world.is_alive(e); // true
world.destroy_entity_deferred(e); // deferred
world.flush();
world.is_alive(e); // falseEntity IDs are generational: destroying an entity increments its slot's generation, so stale IDs are detected as dead.
__DEV__ compile-time flags enable bounds checking, dead entity detection, and duplicate system detection. Circular dependency detection is always active (not tree-shaken). All other dev checks are tree-shaken in production builds.
pnpm install
pnpm test # vitest in watch mode
pnpm bench # run benchmarks
pnpm build # vite library build
pnpm tsc --noEmit # type check- Getting Started — step-by-step tutorial
- Best Practices — performance tips and patterns
- Architecture — internal design details