This page covers the iteration API — Query, chunks (Archetype), forEach and systems — and
the memory API: measuring and reclaiming storage.
For how things work inside, see INTERNALS.md.
const q = world.query({ all: [Position, Velocity], any: [A, B], none: [Frozen] });world.query(desc)returns a cachedQuery. Descriptions that are equal after sorting and removing duplicates return the same instance.q.chunks: Archetype[]lists the matching archetypes in creation order. It is the same array for the query's lifetime. New matching archetypes are appended to it, so you can keep a reference to it.chunkscan include empty archetypes (count === 0). The loop skips them cheaply.q.count()is the number of rows across all chunks, disabled rows included.q.onEnter(cb)/q.onExit(cb)subscribe to entities starting or stopping to match. Each returns an unsubscribe function.
| member | meaning |
|---|---|
count |
live rows |
entities |
Uint32Array of entity handles per row (length is the capacity, read only [0, count)) |
col(C) |
column object of C: one TypedArray per field, keys in schema order (undefined for tags or absent components) |
enabledArray(C) |
Uint8Array of enabled flags (1 = enabled) for enableable components, else undefined |
has(C) |
whether the archetype contains C |
Column arrays go stale when a table is reallocated. All typed columns of an archetype are
views over one buffer, and that buffer is replaced when the table grows — or shrinks, under
shrinkToFit / world.compact() / world.clear() / world.dispose(). After
a spawn, add, remove or destroy that runs immediately, and after a compaction, arrays you got
earlier from col(C).x, enabledArray(C) or entities may point to the old buffer. The column
object returned by col(C) keeps its identity; only its properties are replaced.
So fetch the arrays again in every tick or loop, as in the examples below. Never keep them
across structural changes. world.dispose() is the end state of the same rule: every table is
deflated to a zero-length buffer, so a column array taken before it is a view over a buffer the
world no longer uses, and there is no later call that will refresh it.
new World({ shared: true }) allocates each archetype's buffer as a SharedArrayBuffer (when
the runtime provides one), so columns can be handed to workers; chunk.buffer is the current
buffer and chunk.shared tells which kind it is. The default is a plain ArrayBuffer.
There are three idioms, from fastest to most convenient.
world.system('move', { query: q }, (q) => {
const chunks = q.chunks;
for (let c = 0; c < chunks.length; c++) {
const ch = chunks[c];
const p = ch.col(Position), v = ch.col(Velocity);
const x = p.x, y = p.y, dx = v.dx, dy = v.dy;
for (let i = 0, n = ch.count; i < n; i++) {
x[i] += dx[i];
y[i] += dy[i];
}
}
});- Chunk loops do not skip disabled rows. Check
ch.enabledArray(C)yourself. - Outside a system or
forEach, a chunk loop does not defer structural changes. Inside a system they are deferred, and flushed after the system returns.
q.forEach([Position, Velocity], (entity, chunk, row, pos, vel) => {
pos.x[row] += vel.dx[row];
pos.y[row] += vel.dy[row];
});- The callback gets the column objects of
components(whatchunk.col(C)returns) after(entity, chunk, row), in the order listed. - Columns are resolved once per chunk instead of once per row. The TypeScript types are
inferred from the list, so
pos.xis aFloat32Array. - Rows are visited in reverse order per chunk. Rows where an enableable component of the
query's
alllist is disabled are skipped. Structural changes are deferred until the outermost iteration ends. - A column is
undefinedfor chunks that don't have that component, and always for tags. List components from the query'sall. - The list is read on every call and not kept, so an inline array literal is fine.
- Up to 8 components take the fully specialized path. Longer lists still work, through a slower generic loop.
Measured speed-up over calling chunk.col(C) inside a plain forEach callback (Node 22,
Apple M4, isolated runs, same machine load; see the round-3 report):
| scenario | forEach(fn) |
forEach(components, fn) |
speed-up |
|---|---|---|---|
| packed_5 (5 queries x 1000 rows) | ~112k ops/s | ~187k ops/s | ~1.7x |
| simple_iter (4 chunks x 1000 rows, 2 components) | ~52k ops/s | ~123k ops/s | ~2.4x |
| frag_iter (26 chunks x 100 rows) | ~200k ops/s | ~347k ops/s | ~1.7x |
q.forEach((entity, chunk, row) => {
chunk.col(Position).x[row] += 1;
});It follows the same visiting rules as the columns form. It is convenient, but chunk.col(C)
runs again for every row.
A callback passed to forEach a second time gets its own compiled copy of the row loop, with
a WeakMap cache and at most 1024 copies. That makes the call monomorphic, so V8 can inline the
callback.
- Fresh closures: a closure created anew on every call is never specialized. Hoist callbacks out of hot paths.
- Compiled chunk trampolines are cached by shape.
forEachChunkand the columns form offorEachcompile per-chunk trampolines keyed by (column count, field names, chunk count), not by chunk, so archetype growth, new archetypes and new queries reuse compiled code. At most 4096 distinct shapes are compiled per process (MAX_TRAMPOLINE_SHAPES); later shapes use the generic loop while earlier ones keep their fast path. Details: INTERNALS.md and the section before it. - No runtime code generation: where it isn't allowed (CSP without
'unsafe-eval'), both forms fall back to a shared generic loop.
world.system(name, { query, group = 'update', order = 0 }, (q, dt, world) => { ... });
world.addSystem(MySystemClass, { group, order });
world.update(dt, group);- Systems run in
(order, registration)order. - Each system runs as one iteration level: structural changes it makes are queued and flushed right after it returns.
- The system body is called through a megamorphic call site on purpose. V8 then never inlines
the body into
world.update'stry/finally, where tight loops compile about 1.7x slower. Per-tick overhead is about 14 ns perupdate()with one system. - Fetch columns inside the system body on every tick. They go stale after the table grows
or shrinks, as described above: a
world.compact()orworld.clear()between two ticks replaces the buffer under a column you fetched in the first one.
Tables only ever grow on their own. A wave of destroys, a level teardown or a spawn burst that is over leaves capacity behind, and CozyECS never takes it back by itself.
The ECS provides mechanism, never policy. There is no background GC, no heuristic, no reference tracing and nothing that runs on a tick. Your engine decides when to reclaim; these calls are what make it possible, because the buffers are private.
All of them must be called between ticks. compact(), clear() and dispose() throw if
they are reached from inside a system, a forEach, or a hook that runs during a flush;
compact() and clear() additionally refuse to run with structural commands still queued
(dispose() discards those instead):
CozyECS: world.compact() cannot run during iteration; call it between ticks.
A snapshot of the storage the world owns.
const m = world.memory();
m.entities; // live entities (queued spawns included)
m.tables.used; // sum of count * rowBytes
m.tables.reserved; // sum of buffer.byteLength -- reserved - used is the slack
m.entityIndex; // bytes in the allocator's slot / bigAid / free-stack arrays
m.strings.count; // interned strings, '' included
m.total; // tables.reserved + entityIndex
m.archetypes; // [{ id, components, count, capacity, rowBytes, bytes }, ...] in id orderSizes are bytes of ECS-owned typed-array storage. JS object overhead (archetype descriptors, queries, systems, the interned strings themselves) is not counted.
archetypes lists every archetype the world has created, including the empty root archetype
(id 0, no components) that spawn([...]) routes through. At a large initialCapacity that root
table is not negligible — 400 KB at initialCapacity: 100_000, since its rowBytes is the 4-byte
entity column alone — and compact() deflates it like any other, so stats.archetypes can count
an archetype you never spawned into.
It is diagnostic, so it allocates — one result object, plus one entry and one name array per archetype (~1.4 KB for four archetypes). Call it from a debug overlay or a test, never per frame.
Deflates every archetype to the capacity the growth policy would have reached for its live row count, so the next spawn does not immediately re-grow it. An empty archetype goes to a zero-length buffer.
const stats = world.compact(); // { archetypes, bytesFreed }
world.compact({ minBytes: 0 }); // deflate even a nearly-full table
world.compact({ strings: true }); // also rebuild the string table| option | default | meaning |
|---|---|---|
minBytes |
4096 |
skip an archetype unless deflating it frees at least this many bytes, so compaction never churns tables that are nearly full |
strings |
false |
also rebuild the StringTable, dropping strings no live row references, and rewrite every str column with the new ids |
The target is the growth policy's own capacity for count rows, run forward from the world's
initialCapacity — so initialCapacity is the floor for any non-empty table. A world created
with { initialCapacity: 1_000_000 } and ten live rows in a table frees nothing from it, by
design: that is the capacity the table would have had all along. Only an archetype whose count
is 0 goes to a zero-length buffer.
What survives: archetype objects, their id, mask, key, component list, transition edges and
their membership in every query's chunks. Live entities keep their component values, their
enabled bits and their handles. Only buffers are replaced — so the typed arrays from col(C),
enabledArray(C) and entities go stale exactly as they do on growth.
With { strings: true }, interned ids are not stable across the call. Every str column
inside the world is rewritten in the same pass, so stored values are fine, but an id you are
holding yourself — from world.strings.intern(...) or read with getField(e, C, 'name') — is
meaningless afterwards. stats.strings reports { before, after }. Interning a dropped string
again simply gets a fresh id.
Destroys every entity in one pass instead of one at a time: table counts are reset, every live index goes back to the allocator with its generation bumped, and (by default) every table is deflated.
world.clear(); // fast path: no events, tables deflated
world.clear({ compact: false }); // keep the capacities for an imminent refill
world.clear({ events: true }); // fire onRemove / onExit per entity| option | default | meaning |
|---|---|---|
events |
false |
No onRemove hook and no query onExit listener fires. Skipping the per-entity work is the point of the fast path. true fires them with the same ordering rules as destroy() — hooks in component-id order, then query exits — after every row has already been removed |
compact |
true |
deflate every table to a zero-length buffer afterwards |
If a listener owns something the ECS does not — a sprite, a socket, a GPU resource keyed by
entity — pass { events: true } or tear those down yourself before calling.
Handles taken before the call stay dead, so isAlive keeps answering correctly. Archetypes,
queries, systems, component registrations, transition edges and the string table all survive:
spawning works immediately afterwards, and queries report 0 until it happens.
Note that clear() can make memory().entityIndex grow: the free stack is sized to hold
every index that has ever been handed out. The entity index never shrinks, which is what keeps
old handles reading as dead.
The last rung: the world itself, not its contents. Use it when a world is being thrown away — a level change, a backend switch, a test teardown — and nothing is going to spawn into it again.
world.dispose();
world.disposed; // trueIn order: every registered registerWorldDisposeHook callback runs first, with the world still
fully readable; every system is unregistered and onDestroy() called on class systems in reverse
registration order; every cached query drops its archetypes, its onEnter/onExit listeners and
its compiled loops; onAdd/onRemove listeners are dropped; queued structural commands are
discarded without being applied; every table is deflated to a zero-length buffer and the
archetype list is emptied; the entity index is released, which is what makes every handle read as
dead; and the view cache, component registry and interned strings are dropped.
after dispose() |
behaviour |
|---|---|
spawn, spawnMany, destroy, add, remove, set, enable, archetype, query, onAdd, onRemove, system, addSystem, update, compact, clear, query.onEnter, query.onExit |
throw an Error naming dispose() |
isAlive → false, has → false, get → undefined, query.count() → 0, forEach iterates nothing, memory() all zeros |
safe, answering for an empty world |
dispose(), removeSystem(), flush(), unsubscribe functions handed out earlier |
no-ops |
Measured on a world of 20,000 Position + Velocity entities (initialCapacity: 1024):
memory().tables.reserved goes 404,096 → 0 (400,000 for the table plus 4,096 for the empty root
archetype), memory().total → 0, memory().archetypes → [] and world.strings.size → 1 (the
structural '').
Column references are invalidated, exactly as by growth and compaction — and finally. Any
col(C), enabledArray(C) or entities array taken before the call now views a buffer the
world has let go of, and nothing will hand you a new one.
dispose() is idempotent, and a hook or an onDestroy() that throws does not abort it: the
world is disposed completely and the first error is rethrown afterwards. Components are not
touched — a component() descriptor is process-global and shared by every world, so the same
components keep working in the next world you build.
Dropping the world without disposing it is also fine: nothing in the library holds a world, so it
is collectable on its own (measured in __tests__/leak.test.ts). dispose() is what gives the
bytes back now rather than at the GC's convenience, and what lets attached resources —
cozyecs/gpu's device buffers, through the hook — be released with the world.
The single-table primitive the two calls above are built on. Reallocates the table to
max(count, minCapacity) rows and returns whether it actually reallocated.
"Deflate, don't delete": the archetype object and everything attached to it survive; only the
storage is replaced. A target of 0 leaves a zero-length buffer and zero-length views; such a
table re-grows from capacity 1 on the next pushRow, so call ensureCapacity first when you
know the size of the next burst.
// The world is being refilled: keep it, empty it.
function unloadLevel(world: World, kernel: KernelSystemHandle) {
// Between ticks: not inside a system, and nothing queued.
world.clear(); // every entity gone, every table deflated
world.compact({ strings: true }); // give the string table back too
kernel.releaseUnused(); // and the device buffers (see docs/GPU.md)
}
// The world is going away: one call does all of it, device buffers included.
function unloadGame(world: World) {
world.dispose(); // hooks, systems, queries, tables, entity index
}Both compact() and clear() are cheap to call and free to skip. Reclaiming nothing costs one
pass over the archetype list and a ~40-byte stats object. dispose() is the one that is not
optional-shaped: it ends the world, and there is no call that brings it back.