Skip to content

oasys-works/oecs

Repository files navigation

OECS

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 componentsComponentDef<{x:"f64",y:"f64"}> is just a number at runtime, but enforces field names and types at compile time.
  • Batch iterationfor..of over a query yields non-empty archetypes. Access SoA columns via get_column() and write the inner loop.
  • Single-entity refsctx.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.

Installation

Using npm (or equivalent):

npm install @oasys/oecs

Quick start

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);
}

World Options

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

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

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

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

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; // number

See docs/api/resources.md for full API.

Events & Signals

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

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.

Schedule

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.

Entity lifecycle

const e = world.create_entity();
world.is_alive(e); // true
world.destroy_entity_deferred(e); // deferred
world.flush();
world.is_alive(e); // false

Entity IDs are generational: destroying an entity increments its slot's generation, so stale IDs are detected as dead.

Dev / Prod modes

__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.

Development

pnpm install
pnpm test          # vitest in watch mode
pnpm bench         # run benchmarks
pnpm build         # vite library build
pnpm tsc --noEmit  # type check

Guides

About

Minimal ECS for Typescript

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages