An archetype ECS for performance critical TypeScript applications.
An Entity Component System stores the application state as flat tables instead of object graphs. Data of one kind lives in one contiguous array, and a "system" is a loop over that array.
Apecs is a TypeScript implementation of that idea: entities are plain numbers, component data lives in typed arrays, and iterating a query compiles down to a linear scan over those arrays with no allocation per entity and no per-frame matching work.
It ships React and Solid bindings, a scheduler, relations, change detection, and sorted iteration.
- Lightweight: 18 kB min+gzip. The package is side-effect free, so a bundler drops what you do not import
- Zero dependencies:
reactandsolid-jsare optional peers - High performance: The fastest of the four JS/TS ECS libraries measured on five of eight benchmarks
- Ergonomic and safe: The API removes the classic ECS footguns rather than documenting them, and the ergonomic tier is still the fastest one measured
- Memory efficient: 38 bytes per entity for
Position+Velocity, where the field data itself is 16 and the next-lightest library measured charges 155 bytes
Every figure above is measured, and Benchmarks says on what.
npm install apecsOptional, install the agent skill, so Claude Code and compatible agents know the API and its trade-offs:
npx skills add diffusionstudio/apecs- Quick start · Benchmarks
- Traits · Entities · Reading and writing
- Queries · Order · Relations
- Change detection · Structural changes during iteration
- The frame · React and Solid
- API reference · Requirements · License
import { World, Trait, f32 } from 'apecs';
// Traits are declared once, at module scope.
const Position = new Trait({ x: f32(0), y: f32(0) });
const Velocity = new Trait({ x: f32(0), y: f32(0) });
const IsEnemy = new Trait(); // no data, a tag
const Time = new Trait({ delta: 0 });
const world = new World();
world.add(Time); // no entity argument → the world's own singleton state
const player = world.spawn(Position({ x: 20, y: 10 }), Velocity);
const swarm = world.spawnMany(10_000, Position, Velocity, IsEnemy);
function movement(world: World) {
const dt = world.get(Time.delta);
world.query(Position, Velocity).each((p, v) => {
p.x += v.x * dt;
p.y += v.y * dt;
});
}
function frame(dt: number) {
world.step(); // advance the change-detection tick
world.set(Time, { delta: dt });
movement(world);
}Three things are worth knowing before anything else.
- Traits are global; worlds are isolated. A trait declared at module scope works in any number of worlds with independent storage. A world only pays for the traits it actually uses, so declaring a thousand traits and using twelve costs the same as declaring twelve.
- Entities are numbers, not objects. An entity handle packs a world id, a generation counter and an id into a single 52-bit number. A stale handle fails a liveness check instead of silently pointing at a recycled entity.
- Queries are cached.
world.query(Position, Velocity)inside a system, every frame, is the intended usage: it is a hash lookup returning the same object, and the set of matching data is maintained incrementally rather than recomputed.
Apple M1, Node v20.19.0, against bitECS 0.4.0, koota 0.6.6 and becsy 0.15.5, with a hand-written typed-array loop as the floor. Full method and every number: report · table.
Each cell is a multiple of the hand-written floor; the outlined cell is the fastest in its row.
Column-shaped work, i.e. iteration and query matching, is where an archetype layout wins. Access by handle
(world.get(e, …), accessors) is where it loses: about 13× a flat typed array indexed by entity id.
A workload dominated by random access rather than iteration is better served by a sparse-set ECS.
each hands you a small object per trait; chunks hands you the typed arrays themselves. The
ergonomic tier costs about 2× the raw one on every benchmark in the set.
Entities carrying Position + Velocity, 16 bytes of payload. Everything above that is ids, masks,
archetype bookkeeping and query caches. At a million entities: 38 MB against 155 MB for the
next-lightest library.
A trait is a named, typed piece of data. Declare it once; the argument carries both the shape and the defaults.
import { Trait, f32, u16 } from 'apecs';
const Position = new Trait({ x: f32(0), y: f32(0) }); // struct: one typed array per field
const Health = new Trait({ current: 100, max: 100 }); // bare numbers → Float64Array
const IsActive = new Trait(); // tag: no storage at all
const Mesh = new Trait(() => new THREE.Mesh()); // factory → one boxed columnNested objects are flattened, so { pos: { x: 0 } } becomes the column pos.x. Field order is the
column order and is stable.
| Declaration | Storage | Notes |
|---|---|---|
0 |
Float64Array |
the default for numbers |
f32(0), f64(0) |
Float32Array, Float64Array |
|
i8/i16/i32(0) |
Int8Array, Int16Array, Int32Array |
|
u8/u16/u32(0) |
Uint8Array, Uint16Array, Uint32Array |
|
false, bool(v) |
Uint8Array |
read and written as boolean |
'', str(v) |
Array<string> |
boxed |
eid(0) |
Float64Array |
an entity handle, see below |
a factory () => T |
Array<T> |
one reference per entity |
The markers are typed as their underlying primitive, so Position.x is a number to TypeScript and
schemas read like plain objects.
Fields are values. A trait exposes its fields as properties, and a field is accepted anywhere a single value is wanted:
world.get(e, Position.x); // number, no allocation
world.set(e, Position.x, 20);
world.query(Position).sortBy(Position.x, 'asc');Traits are callable. Calling one pairs it with an initial value, for spawn and add:
world.spawn(Position({ x: 20 }), Velocity, IsActive); // partial init; the rest take defaults
world.spawn(Mesh(existingMesh)); // adopt a reference instead of calling the factoryEntity references. A field declared eid(0) holds an entity handle, and apecs knows it does: on
despawn, every stored reference to that entity is patched to 0. A handle stored in a bare 0
field is not patched and will simply fail its liveness check when read.
const e = world.spawn(Position({ x: 20 }), Velocity, IsActive);
world.isAlive(e); // generation-checked
world.despawn(e); // immediate
world.add(e, Position({ x: 1 }), IsActive);
world.remove(e, Velocity);
world.has(e, Position);Adding or removing a trait moves the entity's row to the archetype (the storage table) for its new trait set. That is a couple of map lookups plus a row copy, not a rehash of the world.
Bulk operations do one transition for the whole set rather than one per entity, which is a large difference on spawn-heavy work:
const swarm = world.spawnMany(10_000, Position, Velocity); // Float64Array of handles
world.addMany(swarm, IsActive);
world.removeMany(swarm, Velocity);
world.despawnMany(swarm);
world.despawnMany(world.query(Dead)); // a query result is a valid batchThe world is an entity too. Id 1 in every world is the world entity, and world-level state such as time, score, paused or the current selection is an ordinary trait on it. Omitting the entity argument targets it:
world.add(Time);
world.set(Time, { delta: 0.016 });
world.get(Time.delta); // number
world.remove(Time);
world.entity; // the handle, if you want itWorld is a plain class, and subclassing is the intended way to extend it
class Game extends World {
public readonly rng = new Rng(1234);
public constructor() {
super({ pageSize: 8192 });
this.add(Time);
}
// Actions: one named place for each way the world changes.
public spawnPlayer(x: number, y: number): Entity {
return this.spawn(Position({ x, y }), Velocity, Health, IsPlayer);
}
public damage(entity: Entity, amount: number): void {
const hp = this.get(entity, Health.current) - amount;
this.set(entity, Health.current, Math.max(0, hp));
}
public reset(): void {
this.clear();
this.add(Time);
}
}world.clear() despawns everything and keeps the world usable. world.destroy() releases it
entirely. world.compact() releases empty storage pages.
Four ways to reach data, fastest last. Choosing between them is the main decision apecs asks you to make, so each row is honest about what it costs.
| Situation | Use | Cost |
|---|---|---|
| One entity, cold path (UI, editor, events) | world.get(e, Position) |
resolves per call; allocates a copy |
| One entity, one value, cold path | world.get(e, Position.x) |
resolves per call; no allocation |
| One entity, hot path, arbitrary order | world.accessor(Position.x) |
resolves once; ~2 lookups per access |
| Many entities, readable | query.each((p, v) => …) |
~2× a raw loop, no allocation |
| Many entities, arithmetic, tens of 1000s | query.chunks() |
~1× a raw loop; no change tracking, no checks |
world.get / world.set resolve the subject on every call. get on a whole struct trait
returns a copy, so it allocates; pass an out object or read a single field to avoid that.
world.get(e, Position); // { x, y }, a copy
world.get(e, Position, out); // writes into `out`, returns it
world.set(e, Position, { x: 5 }); // partial write; fires 'change' observersAccessors do the resolution once and keep it. They are the per-entity escape hatch: pathfinding, physics callbacks, networking, anything addressing entities by handle in an order no query can provide.
const px = world.accessor(Position.x); // memoised per world and field
px.get(e);
px.set(e, 5); // stamps the change tick and fires 'change', exactly like world.seteach is the default for iteration. Each data-bearing trait arrives as a reusable object with
.x / .y properties bound to the current row; the entity handle comes last.
world.query(Position, Velocity).each((p, v) => {
p.x += v.x * dt;
});
world.query(Health, IsEnemy).each((hp, e) => {
// IsEnemy is a tag, it contributes no argument
if (hp.current <= 0) world.defer(() => world.despawn(e));
});Only data-bearing terms contribute arguments. Tags, Not and With contribute none; Optional
contributes one that may be null. This is enforced by the types. A trait declared with a factory
hands back the reference itself rather than a cursor. The objects each hands you are borrowed;
holding one past the callback is a development-build error.
Return false to stop the walk, which is break: the rest of the page, the rest of the
archetype and every archetype after it are skipped, and the walk closes as a completed one does, so
deferred work still drains.
world.query(Position).each((p, e) => {
if (p.x > limit) {
return found(e); // any other value keeps going
}
return false;
});The test is === false, so neither a bare return nor the number an expression body like
(p, v) => (p.x += v.x * dt) evaluates to can stop a walk by accident. The check lives in the row
loop and costs under 1%: 0.560 ms against 0.557 ms over 100 000 entities, inside run-to-run noise.
chunks hands back the typed arrays. A chunk is one page of one matching table; every column in
it is index-aligned with chunk.entities.
for (const chunk of world.query(Position, Velocity).chunks()) {
const { x, y } = chunk.get(Position);
const { x: vx, y: vy } = chunk.get(Velocity);
for (let i = 0, n = chunk.length; i < n; i++) {
x[i] += vx[i] * dt;
y[i] += vy[i] * dt;
}
chunk.markChanged(Position); // the setters were bypassed, say so explicitly
}This tier does no change tracking and no liveness checks. That is the trade, and markChanged is
the part that is easy to forget: without it, Changed() filters miss the write and sorted views do
not re-sort. Development builds warn; production is silent. Views handed out by chunk.get are
valid only for the current step; do not keep them.
markChanged stamps change ticks; it fires no observers. The whole-page form above writes the
current tick across the page's tick array, which suits the usual chunk loop, one that writes every
row. When only some rows were written, pass the row so Changed() does not over-report:
chunk.markChanged(Position, i); // chunk-local row index, not an entity handleThe argument is a row rather than an entity because inside a chunk the row is what you already have;
an entity handle would have to be resolved back to a row through the entity index, which is exactly
the lookup this tier exists to avoid. chunk.entity(i) goes the other way when you need the handle.
world.query(Position, Velocity); // has all of
world.query(Position, Not(Velocity)); // exclusion
world.query(Or(Velocity, Renderable)); // disjunction
world.query(Position, With(IsActive)); // require, but contribute no argument
world.query(Position, Optional(Velocity)); // match either way; the value may be null
world.query(Position, Changed(Position)); // written since this query last ran
world.query(Position, Added(Velocity));
world.query(Position, Removed(Velocity));
world.query(Position, Cascade(ChildOf)); // parents before childrenModifiers nest: Or(Not(A), B). The term list is turned into a test over each storage table once,
when the table is created, so per-frame matching cost is zero.
The result is a small surface:
const q = world.query(Position, Velocity);
q.count; // number of matching entities
q.isEmpty;
q.first; // Entity | undefined; world.queryFirst(...) is sugar for this
for (const e of q) {
} // Tier 1: handles, read values through the world
q.each((p, v, e) => {});
q.chunks();
q.entities(); // Float64Array snapshot, safe to mutate the world while walking itworld.createQuery(...) is the same object under an explicit name, with a dispose() when you want
it out of the cache.
Query results are in storage order, which is not meaningful. Two ways to impose one, with different trade-offs.
sortBy keeps the order in a side array. It supports handles and each, but not chunks.
for (const e of world.query(Sprite).sortBy(Layer.z, 'asc')) {
}
world.query(Sprite).sortBy((a, b) => /* … */ 0); // comparator formorderBy instead rearranges the rows in storage so that row order is key order. It supports
everything, chunks included, because there is nothing extra in the path. But it mutates rows that
every other query over that table sees, and the order is guaranteed per table, not globally. Use
sortBy when the order must be total.
for (const chunk of world.query(Sprite, Layer).orderBy(Layer.z).chunks()) {
}Both are memoised on (query, field, direction), so calling them every frame is a cache lookup.
Both track two levels of staleness: a changed sort key costs a linear re-sort, and a changed set
of matching entities costs a rebuild. A frame in which nothing moved costs one comparison per
matching table and nothing else.
When the key comes from something apecs cannot observe, such as a clock, a camera or a comparator closing over mutable state, say so:
sorted.isDirty; // 'clean' | 'resort' | 'rebuild'
sorted.invalidate(); // force a re-sort on next access
sorted.rebuild(); // force a full rebuildA relation is a trait parameterised by a target entity.
import { Relation, Not, Cascade } from 'apecs';
const ChildOf = new Relation(undefined, { exclusive: true, onTargetDespawn: 'despawn' });
const Likes = new Relation({ amount: 0 });
const child = world.spawn(ChildOf(parent));
world.add(child, Likes(other, { amount: 5 }));
world.query(ChildOf(parent)); // children of one parent
world.query(ChildOf('*')); // anything with a parent
world.query(Position, Not(ChildOf('*'))); // roots
world.target(child, ChildOf); // Entity, 0 when absent
world.targets(e, Likes); // Entity[]Set exclusive: true whenever an entity has at most one target. An exclusive relation stores the
target in a column with an index beside it: one storage table however many parents exist, and
re-targeting costs no table move at all. A non-exclusive relation instead gives every distinct
(relation, target) pair its own id. That is correct for Likes or Owes, and a problem at high fan-out.
Development builds warn when one grows past a threshold.
onTargetDespawn decides what happens to an entity whose target dies: 'remove' (the default) drops
the relation, 'despawn' takes the source with it, 'orphan' keeps a dead target. Cascading
despawn uses an explicit work queue, so deep hierarchies do not overflow the stack, and cycles
terminate.
Cascade(ChildOf) orders a query by hierarchy depth, which turns transform propagation into one
linear pass:
world.query(Position, LocalTransform, Cascade(ChildOf)).each((pos, local) => {
// every parent has already been visited
});Two mechanisms, for two different questions.
Push: observers. Dispatched synchronously, inside the write. Every call returns its unsubscribe.
const off = world.on('add', Position, (entity) => {});
world.on('remove', Mesh, (e) => world.get(e, Mesh).geometry.dispose()); // fires *before* the data goes
world.on('change', Position, (entity) => {});
world.on('add', ChildOf, (entity, target) => {}); // relations pass the target
world.on('enter', world.query(Position, IsActive), (entity) => {});
world.on('exit', world.query(Position, IsActive), (entity) => {});'enter' / 'exit' are usually what you actually want: "started matching this whole query", not
"one trait changed".
Pull: change ticks. The world holds a counter that world.step() advances. Changed, Added
and Removed compare against it, which is a scan of a Uint32Array with no calls in it. Each such
query remembers its own last-seen tick, so two systems watching the same trait do not consume each
other's events.
world.step();
world.query(Position, Changed(Position)).each((p) => {});At scale, prefer pull. Tick storage is allocated only for traits that need it, so untracked traits
pay nothing per write. A trait becomes tracked on its first 'change' subscription, first
Changed() use, first sortBy, or with new Trait(schema, { track: true }).
Ticks are written by world.set, by accessors, and by the objects each hands out. Direct chunk
writes bypass them; call chunk.markChanged(trait, row?) for a page or a row, or
world.markChanged(e, trait) for one entity by handle.
The two are not interchangeable. world.markChanged also fires 'change' observers, exactly as
world.set does. chunk.markChanged only stamps ticks; nothing is dispatched, whether you mark a
row or the page. So a value written through chunks reaches Changed() filters and sorted views,
but never reaches a 'change' observer or, therefore, a mounted React or Solid binding.
The classic ECS footgun, stated explicitly. Tables are walked back to front, and removing a row swaps the last row into its place.
| During iteration | Safe? |
|---|---|
| Reading or writing values on any entity | yes |
| Adding/removing traits on the current entity | yes |
| Despawning the current entity | yes |
| Touching any other entity | defer it |
| Spawning | defer it |
world.query(Position).each((p, e) => {
if (p.y < 0) world.defer(() => world.spawn(Splash({ at: e })));
});
// each() and chunks() flush the deferred queue at the outermost exitworld.defer(fn) queues a closure; world.flush() drains it in order. query.entities() returns a
snapshot copy and is always safe, when deferral is awkward. Development builds detect unsafe
mutation; production builds do not.
Systems are plain functions of the world. Drive them by hand, or with a Schedule, a list of named
systems with before / after constraints, resolved once into a fixed order.
import { Schedule } from 'apecs';
const sim = new Schedule() // owns the clock: run() calls world.step()
.add('movement', movement)
.add('collide', collide, { after: 'movement' })
.add('reap', reap, { after: ['collide', 'movement'] });
const render = new Schedule({ step: false }); // a second schedule must not step
function frame(dt: number) {
world.set(Time, { delta: dt }); // per-frame values ride a trait, not a parameter
sim.run(world);
render.run(world);
}Ordering disturbs registration order as little as the constraints allow, so a new constraint moves
only the systems it names. schedule.order exposes the resolved names. Development builds throw on
an unknown name, a self-constraint or a cycle; production drops the offending edge so every system
still runs exactly once.
Exactly one schedule per frame may advance the clock. The step count is observable: a removal is
visible for exactly one tick, so a second step() can expire a Removed() record before a
once-per-frame system sees it.
apecs/react and apecs/solid are subpath entries of the same package, so there is no version
matrix. They project a mutable, frame-rate-decoupled world into a component tree under two rules:
- Updates are gated on value, not on writes. A simulation writing
Position.x = 4sixty times between paints produces zero re-renders. - Updates coalesce to at most one per animation frame, whatever rate the simulation runs at.
import { WorldProvider, useField, useQuery, useSortedQueryFirst } from 'apecs/react';
<WorldProvider world={world}>…</WorldProvider>; // required; hooks throw without it
const hp = useField(player, Health.current); // a primitive, gated on Object.is
const score = useField(Score.value); // no entity → world trait
const enemies = useQuery(Position, IsEnemy); // readonly Entity[]
const nearest = useSortedQueryFirst([Position, IsEnemy], Distance.value);Solid is the same set with create* names and a bare on for subscriptions, and everything returns
a getter:
import { createField, createQuery } from 'apecs/solid';
const hp = createField(player, Health.current);
const enemies = createQuery(Position, IsEnemy);
hp(); // call itTwo things to internalise:
- Systems iterate; components read single values. Never call
eachorchunksin a render function. - Anything that changes every frame does not belong in a re-render. Use the imperative
subscription (
useOn/on) and write into a ref or a canvas; that is what it is for.
useEntity / createEntity spawn on mount and despawn on unmount. Under React StrictMode, effects
are double-invoked, so a mount burns one entity id.
// declaration
new Trait(schema?, options?) // options: { track?: boolean }
new Relation(schema?, options?) // options: { exclusive?, onTargetDespawn? }
f32 f64 i8 i16 i32 u8 u16 u32 bool str eid
// query terms
Not(term) Or(...terms) With(trait) Optional(trait)
Added(trait) Removed(trait) Changed(trait) Cascade(relation)| World | |
|---|---|
new World(options?) |
{ pageSize?, maxEntities? } |
world.entity |
the world entity's handle |
world.tick / world.step() |
the change-detection counter, and its advance |
world.clear() |
despawn everything, keep the world |
world.compact() |
release empty storage pages |
world.destroy() |
release the world and its id |
| Entities | |
|---|---|
world.spawn(...items) |
→ Entity |
world.spawnMany(n, ...items) |
→ Float64Array, one table transition |
world.despawn(e) / despawnMany(batch) |
a query result is a valid batch |
world.isAlive(e) |
generation-checked |
| Data | |
|---|---|
world.add(e?, ...items) |
omit e to target the world entity |
world.remove(e?, ...traits) |
|
world.addMany(batch, ...items) / removeMany(batch, ...traits) |
|
world.has(e?, trait) |
→ boolean |
world.get(e?, traitOrField, out?) |
a trait returns a copy unless out is given |
world.set(e?, traitOrField, value) |
partial writes allowed; fires 'change' |
world.accessor(field) |
→ { get(e), set(e, v) }, memoised per world and field |
world.markChanged(e, trait) |
after a bypassing write; also fires 'change' |
world.target(e, relation) |
→ Entity (0 when absent), exclusive relations |
world.targets(e, relation) |
→ Entity[] |
| Queries | |
|---|---|
world.query(...terms) |
→ QueryResult, cached by signature |
world.createQuery(...terms) |
the same, with dispose() |
world.queryFirst(...terms) |
→ Entity | undefined |
query.count / .isEmpty / .first |
|
for (const e of query) |
handles |
query.each(fn) |
(...values, entity) => void |
query.chunks() |
iterable of Chunk |
query.entities() |
Float64Array snapshot |
query.sortBy(field, dir?) / sortBy(cmp) |
side-array order; no chunks |
query.orderBy(field, dir?) |
reorders storage; keeps chunks |
view.isDirty / .invalidate() / .rebuild() |
on a sorted or ordered view |
| Chunk | |
|---|---|
chunk.length / chunk.entities |
rows in this page, and their handles |
chunk.get(trait) |
{ x: Float32Array, … } for this page |
chunk.column(field) |
one typed array |
chunk.entity(i) |
→ Entity |
chunk.markChanged(trait, row?) |
ticks only, no observers; page or row |
| Events and deferral | |
|---|---|
world.on('add' | 'remove' | 'change', trait, fn) |
→ unsubscribe |
world.on('enter' | 'exit', query, fn) |
→ unsubscribe |
world.defer(fn) / world.flush() |
each and chunks flush at the outermost exit |
| Schedule | |
|---|---|
new Schedule(options?) |
{ step?: boolean }, default true |
schedule.add(name, system, options?) |
{ before?, after? }; returns the schedule |
schedule.remove(name) / .has(name) / .clear() / .size |
|
schedule.order |
the resolved order, as names |
schedule.run(world) |
world.step(), then every system |
WorldProvider useWorld
useField useTrait useHas useTag // each also takes no entity → world trait
useQuery useQueryFirst useSortedQuery useSortedQueryFirst
useTarget useParent useChildren
useAccessor useEntity useOn<WorldProvider world={world} flush="frame" />. flush is 'frame' (default), 'microtask' or
'sync', and is the bindings' only configuration.
WorldProvider useWorld
createField createTrait createHas createTag
createQuery createQueryFirst createSortedQuery createSortedQueryFirst
createTarget createParent createChildren
createAccessor createEntity onOne-to-one with the React set. Every factory returns a getter.
Node ≥ 20.19, or any current browser. TypeScript consumers need
moduleResolution: 'bundler' or 'node16' to see the subpath types. ESM only. The package is
side-effect free and the bindings stay external, so a bundler drops what you do not import.
Accessor and iteration code is generated at load time with new Function. Under a Content Security
Policy without unsafe-eval, apecs detects that once and falls back to a generic path with the same
semantics, roughly 2–3× slower.
| Limit | Value |
|---|---|
| Entities per world | 2³² − 2 |
| Recycles before an id is retired | 4096 |
| Worlds alive at once | 256 |
| Traits per world, fields per trait | unbounded |
| Page size | power of two, default 4096 |
Not yet included: worker parallelism, serialization and devtools. The storage layout is built to
allow all three without an API break: columns and the entity index are typed arrays throughout, so
a page's backing store can become a SharedArrayBuffer without a rewrite.
MIT © Diffusion Studio Inc.
