Package Exports
- taco-ecs
- taco-ecs/package.json
Readme
taco-ecs
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-ecsSupported runtime target: Node 18+.
Why this package
- archetype storage for dense queries
- typed components, tags, resources, and events
- explicit structural changes via
CommandBuffer - strict
Schedulevalidation with startup systems and stage dependencies - explicit change tracking via
.changed(...)andmarkChanged(...) - 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:
spawnaddset
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); // 1For 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 deferredworld.isAlive(e)only becomestrueafter 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)); // falseSchedule behavior
Schedule is intentionally strict.
- duplicate system names throw
- duplicate stage names throw
- unknown stages throw
- unknown
before/afterreferences 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 = 20ENTITY_GENERATION_BITS = 12MAX_ENTITY_COUNT = 1_048_576MAX_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.mjsSee 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:smokePublishing
Release guidance lives in PUBLISHING.md. The repository includes a CI workflow
and a trusted-publishing npm release workflow under .github/workflows/.