This page covers the iteration API: Query, chunks (Archetype), forEach and systems.
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 grows. All typed columns of an archetype are views
over one buffer, and that buffer is replaced when the table grows. After a spawn, add, remove
or destroy that runs immediately, 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.
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, as described above.