The full guide to the core library: concepts, the API reference, performance tips and limits. For the GPU entry point see GPU.md; for how the internals work see INTERNALS.md; for benchmark methodology see ../benchmarks/RESULTS.md.
The snippets below build on the Quick start: they reuse world, Position, Velocity and
Player.
A component is a schema: field names mapped to field tokens. Each field is stored in its own typed-array column.
| token | column | set / get value |
|---|---|---|
f32, f64 |
Float32Array, Float64Array |
number |
i8, i16, i32 |
Int8Array, Int16Array, Int32Array |
number |
u8, u16, u32 |
Uint8Array, Uint16Array, Uint32Array |
number |
bool |
Uint8Array (0/1) |
boolean |
str |
Uint32Array of interned string ids |
string |
import { component, tag, i32, u8, bool, str } from 'cozyecs';
const Health = component({ hp: i32, max: i32 }, { name: 'Health' });
const Name = component({ value: str }, { name: 'Name' });
const Sprite = component({ visible: bool, layer: u8 }, { name: 'Sprite' });
// A tag has no fields and costs no memory per entity.
const Frozen = tag({ name: 'Frozen' });
// An enableable component can be switched off per entity without moving the entity.
const Ai = component({ state: u8 }, { name: 'Ai', enableable: true });- Components do not belong to a world. Ids come from one counter for the whole process, so define each component once, at module level, and use it with any number of worlds.
nameis only used in error messages.strfields store ids fromworld.strings, which is aStringTable.setandgetconvert for you. Raw columns andgetFieldgive you the id. Id0is always''. Interned strings are never evicted, so do not usestrfor unbounded unique values.
const hero = world.spawn(world.archetype(Position, Name, Health, Sprite));
world.set(hero, Name, { value: 'Ayla' });
world.set(hero, Health, { hp: 30, max: 30 });
world.set(hero, Sprite, { visible: true, layer: 2 });
const heroName = world.get(hero, Name)!.value; // 'Ayla'
const nameId = world.getField(hero, Name, 'value'); // interned id (a number)
const sameName = world.strings.get(nameId); // 'Ayla'
const visible = world.get(hero, Sprite)!.visible; // true- Enableable components use one extra byte per entity for the flag. A disabled component
stays on the entity:
hasstays true and the entity keeps its archetype. Enabling or disabling is immediate and never fires events.forEachskips rows where a component from the query'salllist is disabled. Chunk loops do not skip them: checkchunk.enabledArray(C).
const guard = world.spawn(world.archetype(Position, Ai));
world.enable(guard, Ai, false);
const guardAiOn = world.isEnabled(guard, Ai); // false
const guardHasAi = world.has(guard, Ai); // true
world.enable(guard, Ai); // on again (default `true`)An archetype is the table for one exact set of components. Entities with the same set share a table, one row per entity. The rows are packed with no holes, and the columns are contiguous.
const Bullet = component({ ttl: f32 }, { name: 'Bullet' });
const bulletArch = world.archetype(Position, Velocity, Bullet); // order and duplicates ignored
// One entity: all fields start at zero.
const bullet = world.spawn(bulletArch);
world.set(bullet, Bullet, { ttl: 1 });
// Many entities: storage grows once, and `init` writes straight into the columns.
world.spawnMany(bulletArch, 1000, (chunk, row, i) => {
chunk.col(Position).x[row] = i;
chunk.col(Velocity).dx[row] = 10;
chunk.col(Bullet).ttl[row] = 2;
});Spawn directly into the full archetype. Each add or remove moves the entity to another
table, copying its row. world.spawn(bulletArch) places the entity once. spawn() followed by
three add calls creates it in the empty archetype and then moves it three times.
world.archetype(...)is cached: the same set always returns the sameArchetype. Look it up once and keep it.world.spawn([A, B])(the array form) also works, but it builds a key string on every call. Pass anArchetypeon hot paths.world.spawn()with no argument creates an entity with no components.
An entity is a plain number (a u32): a 20-bit index plus a 12-bit generation. Destroying an
entity frees its index for reuse, and the reused index gets the next generation. A stale handle
therefore stays dead even after its index has been reused.
const temp = world.spawn(world.archetype(Position));
world.destroy(temp);
const tempAlive = world.isAlive(temp); // false
const reused = world.spawn(world.archetype(Position)); // reuses temp's index
const sameIndex = (reused & 0xfffff) === (temp & 0xfffff); // true
const staleStillDead = world.isAlive(temp); // false: different generationdestroyon a dead handle is ignored. So isremove.add,setandenablethrow on a dead handle.getreturnsundefinedfor one.- Handles are only meaningful inside the world that created them.
- The generation wraps after 4096 reuses of the same index. Do not keep handles forever without
checking
isAlive.
const movers = world.query({ all: [Position, Velocity], none: [Frozen] });
const moverCount = movers.count();all: an archetype must have every one of these components.any: at least one of them, if the list is not empty.none: none of them.- Queries are cached. Any description with the same sets returns the same
Query, whatever the order of the components. query.chunksholds the matching archetypes. It is the same array for the query's whole life, and new matching archetypes are appended to it. It can contain empty chunks (count === 0).count()adds up the rows of all chunks, disabled rows included.
There are four ways to iterate, from most direct to most convenient. For raw speed on small loop
bodies, forEachChunk with a callback defined once (or a CPU kernelSystem) beats the plain chunk
loop by about 17%, because only a compiled loop lets V8 treat the column arrays as constants (see
Performance tips). On heavier bodies and large tables the difference fades.
1. Chunk loops. A plain loop over typed arrays. Rows are 0..count-1 in each chunk.
for (const chunk of movers.chunks) {
const p = chunk.col(Position), v = chunk.col(Velocity);
const x = p.x, dx = v.dx, ents = chunk.entities;
for (let i = chunk.count - 1; i >= 0; i--) {
if (x[i] > 10_000) world.destroy(ents[i]);
else x[i] += dx[i];
}
}A destroy inside a chunk loop that is not running in a system applies immediately. It swaps
the chunk's last row into row i and shrinks count. That is why the loop above visits rows in
reverse order. A forward loop would skip the swapped-in row. You can also run the loop inside a
system, where changes are deferred, or collect the handles first.
Chunk loops ignore enabled flags. Check them yourself:
const thinkers = world.query({ all: [Ai] });
for (const chunk of thinkers.chunks) {
const on = chunk.enabledArray(Ai)!;
const state = chunk.col(Ai).state;
for (let i = 0; i < chunk.count; i++) if (on[i] === 1) state[i]++;
}2. forEachChunk(components, fn). A chunk loop with the columns resolved for you. fn
receives (count, ...columns, chunk). If you define fn once and pass the same function every
time, CozyECS compiles a specialized loop for it, which is faster than a hand-written chunk loop
(1.16–1.30x bitecs vs 0.78–0.86x for the plain loop on a 2-add body, 1k–50k entities).
const integrate = (n: number, pos: { x: Float32Array; y: Float32Array }, vel: { dx: Float32Array; dy: Float32Array }) => {
const x = pos.x, y = pos.y, dx = vel.dx, dy = vel.dy;
for (let i = 0; i < n; i++) { x[i] += dx[i]; y[i] += dy[i]; }
};
movers.forEachChunk([Position, Velocity], integrate);3. forEach(components, fn). A callback per row, with the columns resolved once per chunk.
movers.forEach([Position, Velocity], (entity, chunk, row, pos, vel) => {
pos.y[row] += vel.dy[row];
});4. forEach(fn). A callback per row with (entity, chunk, row).
movers.forEach((entity, chunk, row) => {
if (chunk.col(Position).y[row] < 0) world.add(entity, Frozen); // deferred until forEach returns
});Both forEach forms visit rows in reverse order within each chunk. They skip rows where an
enableable component from all is disabled, and they defer structural changes (see
Deferred structural changes). A column is undefined for a tag,
or for a component that the chunk does not have, so list only components from all.
A system is a named unit of per-tick work. It belongs to a group (default 'update') and
has an order (default 0; lower runs first, and ties run in registration order).
world.update(dt, group) runs every enabled system of that group.
Function systems receive (query, dt, world):
const drawn: number[] = [];
const gravity = world.system('gravity', { query: { all: [Velocity] }, order: -10 }, (q, dt) => {
for (const chunk of q.chunks) {
const dy = chunk.col(Velocity).dy;
for (let i = 0; i < chunk.count; i++) dy[i] -= 9.8 * dt;
}
});
world.system('draw', { group: 'render', query: { all: [Position, Player] } }, (q) => {
for (const chunk of q.chunks) for (let i = 0; i < chunk.count; i++) drawn.push(chunk.entities[i]);
});Class systems extend System. Field initializers can already use this.query(...) and
this.world:
import { System } from 'cozyecs';
class Lifetime extends System {
bullets = this.query({ all: [Bullet] });
expired = 0;
onCreate() {
// called once, right after registration
}
onUpdate(dt: number) {
for (const chunk of this.bullets.chunks) {
const ttl = chunk.col(Bullet).ttl;
const ents = chunk.entities;
for (let i = 0; i < chunk.count; i++) {
ttl[i] -= dt;
if (ttl[i] <= 0) {
this.world.destroy(ents[i]); // queued; applied right after this system returns
this.expired++;
}
}
}
}
onDestroy() {
// called by world.removeSystem
}
}
const lifetime = world.addSystem(Lifetime, { order: 10 });A game loop runs its groups separately:
world.update(1 / 60); // 'update' group: gravity (-10), move (0), Lifetime (10)
world.update(0, 'render'); // 'render' group: draw
gravity.enabled = false; // skipped by update() until re-enabled
world.removeSystem(lifetime); // unregisters it and calls onDestroy()spawn, spawnMany, destroy, add and remove are structural changes, because they move
rows between tables. Inside a system run, a forEach or a forEachChunk, they are queued in a
command buffer instead of being applied. The buffer is flushed:
- right after each system returns (inside
world.update), - when the outermost
forEachorforEachChunkreturns, - when you call
world.flush()outside any iteration.
Outside those contexts, structural changes apply immediately.
While a change is queued:
spawnreturns a handle that is already alive, but it has no components yet:hasis false,getisundefined, andsetthrows.destroyleaves the entity alive and readable until the flush.add(e, C, values)keeps a reference tovaluesand reads it at flush time, so do not reuse or mutate that object.- A command whose entity died before the flush is skipped.
set, get, getField, enable, isEnabled and has are never deferred.
let shot = -1;
let shotPlacedInside = true;
world.system('spawner', {}, (_q, _dt, w) => {
shot = w.spawn(bulletArch); // alive now, placed after this system returns
shotPlacedInside = w.has(shot, Bullet); // false here
});
world.update(0);
const shotPlacedAfter = world.has(shot, Bullet); // truelet added = 0, removed = 0, entered = 0, exited = 0;
const offAdd = world.onAdd(Health, (e) => { added++; }); // add, spawn, spawnMany
const offRemove = world.onRemove(Health, (e) => { removed++; }); // remove, destroy
const offEnter = movers.onEnter((e) => { entered++; }); // starts matching the query
const offExit = movers.onExit((e) => { exited++; }); // stops matching the query
const npc = world.spawn(world.archetype(Position, Velocity, Health)); // added = 1, entered = 1
world.add(npc, Frozen); // exited = 1
world.destroy(npc); // removed = 1
offAdd(); offRemove(); offEnter(); offExit(); // unsubscribe (safe to call twice)- Events fire after the change has been applied. On destroy, the handle is already dead, so its data can no longer be read.
- For one change, the order is:
onRemovehooks, thenonAddhooks, then query exit/enter. setandenablenever fire events.- A callback may make structural changes itself. They are deferred if the world is iterating or flushing.
- Events cost nothing when nobody listens. With listeners, flushes take a slower path.
All columns of an archetype (entities, every field and every enabled flag) are views over one
buffer. new World({ shared: true }) allocates these buffers as SharedArrayBuffer, so columns
can be sent to workers without copying. If SharedArrayBuffer is not available (for example in
a browser page without cross-origin isolation), CozyECS uses a plain ArrayBuffer.
const sharedWorld = new World({ shared: true, initialCapacity: 4096 });
const particles = sharedWorld.archetype(Position, Velocity);
sharedWorld.spawnMany(particles, 1000);
const isShared = particles.shared; // true in Node and cross-origin-isolated pages
const xs = particles.col(Position).x; // Float32Array over particles.buffer
// worker.postMessage({ xs, count: particles.count }); // the worker sees the same memoryColumns go stale when a table grows. When a table runs out of capacity, CozyECS allocates a
new, larger buffer and copies the rows. Typed arrays you got earlier from col(C).x,
enabledArray(C) or entities still point at the old buffer. The object returned by col(C)
keeps its identity; only its properties are replaced.
sharedWorld.spawnMany(particles, 10_000); // 11,000 rows > capacity 4096: the table grows
const stale = xs !== particles.col(Position).x; // true: re-fetch after structural changes- Fetch column arrays inside each system run or loop. Never keep them across spawns, adds, removes or destroys that apply immediately.
- After growth, a worker holding old arrays keeps reading the old buffer. Send it the new
arrays, or set
initialCapacityso the table never grows. shared: truecosts no memory. It measured within ±2% in most scenarios and 6–9% slower in one fragmented-iteration case (details). Keep the default (false) unless you use workers.
EXPERIMENTAL. The API, the kernel subset and the break-even constants may change in any minor release. Full documentation: docs/GPU.md.
cozyecs/gpu is a separate, opt-in entry point. It lets you write a system as
a plain JavaScript function. CozyECS parses it (no dependencies), compiles it to
WGSL for WebGPU, and also compiles it to a flat CPU chunk loop. The core
cozyecs bundle never imports it.
import { kernelSystem } from 'cozyecs/gpu';
// Position { x, y } and Velocity { dx, dy } from the quick start.
const fall = await kernelSystem(world, 'Fall', {
components: [Position, Velocity], // kernel parameters, matched BY POSITION
uniforms: { gravity: -9.8 }, // the only way to pass outside values in
target: 'auto', // 'gpu' | 'cpu' | 'auto'
readback: 'async', // 'async' | 'sync-frame' | 'none'
kernel: (p, v, dt, u) => {
v.dy += u.gravity * dt;
p.x += v.dx * dt; p.y += v.dy * dt;
if (p.y < 0) { p.y = 0; v.dy = -v.dy * 0.5; }
},
});
world.update(1 / 60); // dispatches inside the system's group, never blocks-
Free identifiers are errors. A closure variable cannot be read from
Function.prototype.toString, sop.y += gravity * dtfails at registration withE_UNKNOWN_IDENTIFIERtelling you to useuniforms: { gravity }andu.gravity.Math.random, strings, objects, your own function calls and anyworldaccess are also rejected. -
Readback.
'async'(default) applies GPU results next frame, so CPU code reads last frame's values.'sync-frame'lets youawait fall.sync()(orflushKernels(world, group)) before the frame ends: slower, but current.'none'never reads back; usefall.bufferFor(archetype)to render straight from the GPU buffer. If you dispatch faster than the GPU completes readbacks, frames are coalesced, never lost:sync()still waits for every frame. -
target: 'auto'picks CPU or GPU on every dispatch. The defaults are 45k entities ('async'/'none') and 700k ('sync-frame') for a baseline-cost kernel (1.14 ns/entity on the CPU), scaled by the kernel's estimated CPU cost. They are one machine's numbers (an M4 through Dawn); override them withsetAutoThresholds()or thethresholdsoption. -
Measured break-even (entities above which the GPU beats the compiled CPU loop, M4 + Dawn, median of 5 isolated runs, every cell checked row by row):
kernel 'none''async''sync-frame'simple ( p.x += v.dx; p.y += v.dy)~49k ~64k never, up to 1M gravity (integrate + bounce) ~23k ~34k ~554k autokeeps smaller queries on the CPU. At 1M entities the GPU with'none'runs the simple kernel in 0.35 ms against 1.80 ms on the CPU, but below ~20k it is always slower (a ~0.06 ms per-frame floor).'sync-frame'rarely pays off. -
The CPU backend is a speedup on its own.
kernelSystem(world, name, { target: 'cpu', ... })compiles the kernel into a loop with every column as a constant: 1.08–1.30x bitecs on the simple kernel up to 50k entities and 1.04–1.15x on gravity (1k–1M, leaving out an outlier bitecs size). -
Never throws for a capability reason. If there is no WebGPU device (e.g. Node without
setGPUProvider), the kernel uses a GPU-incompatible field, or the query includes an enableable component, the kernel falls back to the CPU backend and warns once. The CPU backend skips disabled rows exactly. If no backend can run at all,fall.backend === 'none'and you get one warning; the failure is never silent. -
f64fields are computed in f32 (one warning per kernel). -
Pairwise kernels (
pairwise: true,(self, other, dt, u) => ...) are O(n²) and only form pairs within one archetype. -
Calibrate
autoon the user's machine. The built-in switch points were measured on one Apple M4.await calibrateAuto(cozy)(withimport * as cozy from 'cozyecs') measures the CPU/GPU break-even on the current device in ~0.7 s and applies it to kernels created afterwards (see docs/GPU.md, "Choosing a target"). -
Status: experimental. The parser, CPU backend and GPU backend are covered by parity and fuzz tests, in Node (Dawn over Metal) and in Chrome's WebGPU.
| function | description |
|---|---|
component(schema, { name?, enableable? }) |
Defines a component type. Returns a frozen ComponentType with id, name, schema, keys, tokens, isTag and enableable. |
tag({ name?, enableable? }) |
Defines a component with no fields. |
f32 f64 i8 i16 i32 u8 u16 u32 bool str |
Field type tokens. |
| member | description |
|---|---|
new World({ initialCapacity?, shared? }) |
initialCapacity sets the starting rows per archetype and the size of the entity arrays (default 64). shared allocates tables as SharedArrayBuffer (default false). |
strings: StringTable |
Intern table for str fields: intern(s), get(id), size. |
archetype(...components): Archetype |
Gets or creates the archetype for a component set. Cached. |
spawn(archetype? | components[]): number |
Creates an entity with all fields zeroed. Deferred while iterating. |
spawnMany(archetype, count, init?) |
Creates count entities and grows storage once. init(chunk, row, i) runs for each new row. Deferred while iterating. |
destroy(entity) |
Destroys an entity. Ignored if it is dead. Deferred while iterating. |
isAlive(entity): boolean |
True for live handles, including a spawn that is still queued. |
add(entity, C, values?) |
Adds C, optionally with values. If the entity already has C, only sets the values. Throws if the entity is dead. Deferred while iterating. |
remove(entity, C) |
Removes C. No-op if the entity is dead or lacks C. Deferred while iterating. |
has(entity, C): boolean |
True if the entity is alive, placed, and has C. |
set(entity, C, values) |
Writes the given fields immediately. Keys that are not in the schema are ignored. Throws if the entity is dead or lacks C. |
get(entity, C) |
Reads all fields into a view object that is cached per component and overwritten on the next call, so copy what you need. undefined if the entity is dead or lacks C. |
getField(entity, C, key): number |
Raw column value (str gives the id, bool gives 0/1). The entity must have C. |
enable(entity, C, on = true) |
Sets an enableable component's flag. Throws if C is not enableable, or if the entity is dead or lacks C. |
isEnabled(entity, C): boolean |
False if the entity is dead or lacks C. True if C is not enableable. Otherwise the flag. |
onAdd(C, cb) / onRemove(C, cb) |
Component hooks. Each returns an unsubscribe function. |
query({ all?, any?, none? }): Query |
Gets or creates a cached query. |
system(name, { query?, group?, order? }, fn): SystemHandle |
Registers a function system fn(query, dt, world). query can be a Query or a description. |
addSystem(SystemClass, { group?, order? }) |
Constructs a class system, registers it, calls onCreate() and returns the instance. |
removeSystem(system) |
Unregisters a system, sets enabled = false and calls onDestroy() for class systems. |
update(dt = 0, group = 'update') |
Runs the group's enabled systems in order, flushing after each one. If a system throws, pending commands are flushed and the error propagates. |
flush() |
Applies queued structural changes. No-op while iterating or already flushing. |
| member | description |
|---|---|
chunks: Archetype[] |
Matching archetypes, in creation order. The same array for the query's whole life. |
all, any, none |
The normalized component lists. |
count(): number |
Total rows across all chunks, disabled rows included. |
matches(archetype): boolean |
Whether an archetype matches the query. |
forEach(fn) |
fn(entity, chunk, row) for every enabled row. |
forEach(components, fn) |
fn(entity, chunk, row, ...columns). Columns are resolved once per chunk. |
forEachChunk(components, fn) |
fn(count, ...columns, chunk) once per non-empty chunk. Does not filter by enabled flags. |
onEnter(cb) / onExit(cb) |
cb(entity) when an entity starts or stops matching. Each returns an unsubscribe function. |
| member | description |
|---|---|
count: number |
Live rows. Rows 0..count-1 are packed. |
capacity: number |
Allocated rows. |
entities: Uint32Array |
The handle in each row. Read only [0, count). |
col(C) |
Column object of C, with one typed array per field. undefined for a tag or a component the archetype lacks. |
enabledArray(C): Uint8Array | undefined |
Enabled flags (1 = on) for an enableable C. |
has(C): boolean |
Whether the archetype contains C. |
isEnabled(C, row): boolean |
The flag for one row. |
components |
The component types, sorted by id. |
buffer, shared, rowBytes |
The backing buffer, whether it is a SharedArrayBuffer, and bytes per row. |
The typed arrays from entities, col(C) and enabledArray(C) are replaced when the table
grows (see column invalidation).
| member | description |
|---|---|
SystemHandle |
Returned by world.system. Fields: name, group, order, query, fn and a writable enabled. |
abstract class System |
Fields: world, name, group, order and enabled. query(desc) is shorthand for world.query. Implement onUpdate(dt). onCreate() and onDestroy() are optional. |
Exported types: WorldOptions, SpawnInitFn, SystemOptions, FunctionSystemOptions,
SystemFn, SystemClass, ComponentType, ComponentOptions, Schema, ColumnsOf,
ValuesOf, FieldToken, TypedArray, EntityCallback, QueryDesc, QueryForEachFn,
QueryForEachColumnsFn, ColumnsTuple and Chunk.
More detail: docs/API.md covers iteration and systems, and docs/INTERNALS.md covers storage, entities and the command buffer.
- Iterate chunks, not entities, and let the library compile the loop. The fastest forms
are
kernelSystem(world, name, { target: 'cpu', ... })fromcozyecs/gpuandforEachChunkwith a callback defined once: both run a compiled loop in which V8 treats the column arrays as constants. A plain chunk loop you write yourself (for (let i = 0; i < chunk.count; i++) x[i] += dx[i]) is about 17% slower on tiny loop bodies, because V8 cannot treat its arrays as constants; see Why the plain chunk loop trails closure-constant loops. UseforEachwhere convenience matters more. - Define callbacks once.
forEachandforEachChunkcompile a specialized loop for a callback they see a second time. A closure created fresh on every call never gets one. Compiled loops are cached by shape (column count and field names), so archetype growth, new archetypes and new queries reuse existing code. The only bound is 4096 distinct shapes per process (MAX_TRAMPOLINE_SHAPES), plus 1024 specializedforEachcopies; past it, new shapes run on the generic loop and everything compiled earlier keeps its fast path. Earlier builds counted every rebuild against a lifetime cap, so a long-running app could silently lose its compiled loops; that is fixed. - Spawn into the final archetype, and use
spawnManywith aninitfor batches. Avoidspawn()followed by a chain ofaddcalls. - Keep hot structural changes in systems or
forEach. Queued commands are applied in batches. The entity_cycle and add_remove benchmarks run 1.6–4x faster than the best competitor either way. - Prefer enableable components to add/remove for state that toggles often. A flip is one byte write and does not move the row.
- Pass
Archetypeobjects, not arrays, tospawnon hot paths. The array form builds a key string. - Re-fetch columns after structural changes. A table that grows replaces its buffer.
- Don't use
getin hot loops. It converts every field into an object. UsegetFieldor the columns. - Set
initialCapacitywhen you know roughly how many entities you will have, to avoid repeated growth. - Leave
sharedoff unless you actually hand columns to workers.
- 1,048,576 (2^20) live entities per world.
- 32,768 archetypes per world.
- 12-bit generations: a handle's index is reused with a new generation, and the generation wraps after 4096 reuses.
- Structural changes and
forEachmust run on the thread that owns the world. Withshared: true, other threads may read and write column values.