JSPM

taco-ecs

0.1.3
  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 4
  • Score
    100M100P100Q58261F
  • License MIT

Archetype-based Entity-Component-System for TypeScript and JavaScript.

Package Exports

  • taco-ecs
  • taco-ecs/package.json

Readme

taco-ecs

npm version license

taco-ecs is an archetype-based ECS for TypeScript and JavaScript with safe component ownership, strict scheduler validation, explicit structural changes, and dual ESM/CommonJS packaging that is validated through the packed artifact.

It is built for systems where many items move through the same stages:

  • games and game-style simulations
  • agent swarms and rule-driven simulations
  • workflow engines and ticket pipelines
  • service backends with repeatable update phases
  • operational systems that benefit from dense scans over shared state

Installation

npm install taco-ecs

Supported runtime target: Node 18+.

Why this package

  • archetype storage for dense queries
  • typed components, tags, resources, and events
  • explicit structural changes via CommandBuffer
  • strict Schedule validation with startup systems and stage dependencies
  • explicit change tracking via .changed(...) and markChanged(...)
  • packaged for both ESM and CommonJS consumers
  • validated as a real npm artifact through pack and install smoke tests

Module support

ESM:

import { World, defineComponent } from "taco-ecs";

CommonJS:

const { World, defineComponent } = require("taco-ecs");

Core model

taco-ecs is an archetype ECS:

  • entities are 32-bit generational handles
  • components define structural identity and data columns
  • structural changes move entities between archetypes
  • queries iterate matching archetypes and rows

That means add() and remove() are structural operations, not simple field assignments. If you need to record structural work safely while iterating, use CommandBuffer and flush after the pass or let Schedule flush for you.

Quick start

import { World, defineComponent } from "taco-ecs";

const Position = defineComponent({
  name: "Position",
  create: () => ({ x: 0, y: 0 }),
});

const Velocity = defineComponent({
  name: "Velocity",
  create: () => ({ x: 0, y: 0 }),
});

const world = new World();
const entity = world.spawn([
  [Position, { x: 10, y: 20 }],
  [Velocity, { x: 1, y: -2 }],
]);

world.beginFrame();
world.query(Position, Velocity).forEach((e, pos, vel) => {
  pos.x += vel.x;
  pos.y += vel.y;
  world.markChanged(e, Position);
});

console.log(world.get(entity, Position));

Good fits

Use this package when you have a lot of entities or items that:

  • share a small set of component shapes
  • move through repeatable update stages
  • benefit from dense scans rather than object-by-object polymorphism
  • need clear rules for deferred structural edits

If your problem is mostly a few deeply nested custom objects with little shared iteration, an ECS may not be the right fit.

Component ownership

Caller-provided values are cloned when they enter the world through:

  • spawn
  • add
  • set

That keeps external objects from aliasing entity-owned state by accident.

const Position = defineComponent({
  name: "Position",
  create: () => ({ x: 0, y: 0 }),
});

const shared = { x: 1, y: 2 };
const world = new World();
const a = world.spawn([[Position, shared]]);
const b = world.spawn([[Position, shared]]);

world.get(a, Position).x = 99;

console.log(shared.x);                 // 1
console.log(world.get(b, Position).x); // 1

For custom classes or special storage, provide a custom clone function.

const Transform = defineComponent({
  name: "Transform",
  create: () => new Float32Array(16),
  clone: (value) => value.slice(),
});

Change tracking

.changed(Component) is explicit.

A component counts as changed in the current frame when you:

  • call world.set(entity, Component, value), or
  • mutate the live component in place and then call world.markChanged(entity, Component)

Direct in-place mutation without markChanged is visible to plain queries, but it is not visible to .changed(...) filters.

world.beginFrame();
const pos = world.get(entity, Position);
pos.x += 1;
world.markChanged(entity, Position);

world.query(Position).changed(Position).forEach((_e, changedPos) => {
  console.log(changedPos);
});

Reserved handles and command buffers

CommandBuffer.spawn() reserves an entity handle immediately and materializes it on flush().

  • world.isReserved(e) tells you whether a handle is still deferred
  • world.isAlive(e) only becomes true after the buffered spawn is flushed
  • normal component access throws while the entity is reserved
import { CommandBuffer } from "taco-ecs";

const commands = new CommandBuffer(world);
const reserved = commands.spawn([[Position, { x: 1, y: 2 }]]);

console.log(world.isAlive(reserved));    // false
console.log(world.isReserved(reserved)); // true

commands.flush();

console.log(world.isAlive(reserved));    // true
console.log(world.isReserved(reserved)); // false

Schedule behavior

Schedule is intentionally strict.

  • duplicate system names throw
  • duplicate stage names throw
  • unknown stages throw
  • unknown before / after references throw
  • cross-stage dependency references throw
  • dependency cycles throw with the blocked system names

Startup systems are supported through addStartup(name, fn). They run exactly once per Schedule instance, on the first run(world, dt), after world.beginFrame() and before regular stage execution.

import { Schedule } from "taco-ecs";

const schedule = new Schedule()
  .setStageOrder(["pre", "update", "post"])
  .addStartup("bootstrap", ({ commands }) => {
    commands.spawn([[Position, { x: 0, y: 0 }], [Velocity, { x: 1, y: 0 }]]);
  })
  .add("movement", ({ world }) => {
    world.query(Position, Velocity).forEach((e, pos, vel) => {
      pos.x += vel.x;
      pos.y += vel.y;
      world.markChanged(e, Position);
    });
  }, { stage: "update" });

schedule.run(world, 1 / 60);

Resources

import { defineResource } from "taco-ecs";

const Time = defineResource<{ dt: number }>("Time");
world.setResource(Time, { dt: 1 / 60 });
console.log(world.getResource(Time).dt);

Entity handle limits

Entity handles are 32-bit packed values with:

  • ENTITY_INDEX_BITS = 20
  • ENTITY_GENERATION_BITS = 12
  • MAX_ENTITY_COUNT = 1_048_576
  • MAX_ENTITY_GENERATION = 4095

That gives you about one million live entity slots per world before allocation fails. Handles are generational, so stale references become invalid after recycling. The four constants above are exported from the package root.

Secondary capabilities

The package also includes:

  • typed event buffers
  • component add/remove observers
  • world snapshot serialization helpers

Those are useful secondary tools, but the core package pitch is still the ECS runtime: world, queries, structural changes, schedules, and resources.

Examples

From a cloned repository, run npm run build first. The published package already includes built output.

node examples/01_quick_start.mjs
node examples/02_particles.mjs
node examples/03_change_tracking.mjs
node examples/04_schedule_resources.mjs
node examples/05_hierarchy_pattern.mjs
node examples/06_service_pipeline.mjs
node examples/07_ticket_workflow.mjs
node examples/08_workflow_pipeline.mjs

See INTEGRATION.md for guidance on choosing world boundaries, stages, resources, and events for real applications.

Development

npm ci
npm run check
npm test
npm run bench
npm run smoke
npm run examples:smoke
npm run pack:check
npm run install:smoke

Publishing

Release guidance lives in PUBLISHING.md. The repository includes a CI workflow and a trusted-publishing npm release workflow under .github/workflows/.