aiecsjs 0.5.7 → 0.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -935
- package/README_ZHTW.md +51 -945
- package/dist/{chunk-P5GW7GKY.cjs → chunk-3FV6UMUS.cjs} +2 -2
- package/dist/chunk-3FV6UMUS.cjs.map +1 -0
- package/dist/{chunk-SRX2MZPX.cjs → chunk-3ZLNR4JX.cjs} +2 -2
- package/dist/{chunk-SRX2MZPX.cjs.map → chunk-3ZLNR4JX.cjs.map} +1 -1
- package/dist/{chunk-22ICWJ5O.js → chunk-N5OETPTK.js} +2 -2
- package/dist/{chunk-22ICWJ5O.js.map → chunk-N5OETPTK.js.map} +1 -1
- package/dist/{chunk-AGWUE6JB.js → chunk-TNZMD7E5.js} +2 -2
- package/dist/chunk-TNZMD7E5.js.map +1 -0
- package/dist/{chunk-2KCN5RVK.js → chunk-YFTCD2UG.js} +2 -2
- package/dist/chunk-YFTCD2UG.js.map +1 -0
- package/dist/{chunk-5QWEV4VJ.cjs → chunk-ZIZSWIHW.cjs} +2 -2
- package/dist/chunk-ZIZSWIHW.cjs.map +1 -0
- package/dist/commands.cjs +1 -1
- package/dist/commands.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/observers.cjs +1 -1
- package/dist/observers.js +1 -1
- package/dist/relations.cjs +1 -1
- package/dist/relations.cjs.map +1 -1
- package/dist/relations.js +1 -1
- package/dist/relations.js.map +1 -1
- package/dist/serialize.cjs +1 -1
- package/dist/serialize.js +1 -1
- package/dist/worker.cjs +1 -1
- package/dist/worker.js +1 -1
- package/llms-full.txt +109 -1538
- package/llms.txt +8 -29
- package/package.json +1 -1
- package/dist/chunk-2KCN5RVK.js.map +0 -1
- package/dist/chunk-5QWEV4VJ.cjs.map +0 -1
- package/dist/chunk-AGWUE6JB.js.map +0 -1
- package/dist/chunk-P5GW7GKY.cjs.map +0 -1
package/README.md
CHANGED
|
@@ -1,964 +1,79 @@
|
|
|
1
1
|
# aiecsjs
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[](https://github.com/islumina/aiecsjs/actions/workflows/ci.yml)
|
|
5
|
-
[](LICENSE)
|
|
6
|
-
[](https://www.anthropic.com/claude-code)
|
|
7
|
-
[](README_ZHTW.md)
|
|
3
|
+
TypeScript-first archetype ECS with TypedArray SoA components, command buffers, relations, serialization, and SAB-ready snapshot transport.
|
|
8
4
|
|
|
9
|
-
>
|
|
10
|
-
|
|
11
|
-
Part of the [ai\*js micro-runtime ecosystem](https://github.com/islumina) — see also [aifsmjs](https://github.com/islumina/aifsmjs) (FSM) and [aibridgejs](https://github.com/islumina/aibridgejs) (cross-context RPC).
|
|
12
|
-
|
|
13
|
-
aiecsjs uses **archetype tables with TypedArray columns** and **bitmask queries** — the same architecture that powers piecs and wolf-ecs at the top of public benchmarks. Its API is **functional and tree-shakable**, composed with `pipe()`. Components support both Structure-of-Arrays (SoA) and Array-of-Structures (AoS) layouts. Since 0.3, `EntityId` packs index + generation into a single 32-bit number; the ABA-safe `EntityRef` API shipped in 0.3.0.
|
|
14
|
-
|
|
15
|
-
```ts
|
|
16
|
-
import { createWorld, createEntity, addComponent, defineComponent, defineQuery, pipe, forEachEntityIndexed, Types } from 'aiecsjs'
|
|
17
|
-
|
|
18
|
-
const Position = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
19
|
-
const Velocity = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
20
|
-
|
|
21
|
-
const world = createWorld()
|
|
22
|
-
const eid = createEntity(world)
|
|
23
|
-
addComponent(world, eid, Position, { x: 0, y: 0 })
|
|
24
|
-
addComponent(world, eid, Velocity, { x: 1, y: 2 })
|
|
25
|
-
|
|
26
|
-
const movers = defineQuery([Position, Velocity])
|
|
27
|
-
const movement = (w, dt) => { forEachEntityIndexed(w, movers, (e, i, pos, vel) => { pos.x[i] += vel.x[i] * dt; pos.y[i] += vel.y[i] * dt }); return w }
|
|
28
|
-
|
|
29
|
-
pipe(movement)(world, 1/60)
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
> **Use `forEachEntityIndexed` for column iteration.** Its callback is `(e, i, ...cols)`: `e` is the packed `EntityId` (use it directly for `destroyEntity`, `hasComponent`, `getComponent`, command buffers), and `i` is the **safe column subscript** — index every SoA column with it (`pos.x[i]`). The packed `e` is **not** a column index: it only equals the index until a slot is recycled (generation 0); after any `destroyEntity` recycles a slot, `e !== i` and indexing a column with `e` reads the wrong slot (out of bounds → `undefined`/`NaN`). `forEachEntityIndexed` hands you the correct `i` so this footgun is closed in code. Use `forEachEntity` when you only need the `EntityId` (and index columns via `getEntityIndex(e)` if you must).
|
|
33
|
-
|
|
34
|
-
> **Status: 0.5.7 — experimental (0.5.x line).** The API surface in `STABILITY.md` is committed for the 0.x line, but expect adjustments. A stable 1.0 freeze is targeted after community feedback.
|
|
35
|
-
|
|
36
|
-
## Table of contents
|
|
37
|
-
|
|
38
|
-
- [Why aiecsjs?](#why-aiecsjs)
|
|
39
|
-
- [Install](#install)
|
|
40
|
-
- [Quick Start](#quick-start)
|
|
41
|
-
- [Core Concepts](#core-concepts)
|
|
42
|
-
- [Guide](#guide)
|
|
43
|
-
- [API Reference](#api-reference)
|
|
44
|
-
- [Performance](#performance)
|
|
45
|
-
- [Multi-threading Guide](#multi-threading-guide)
|
|
46
|
-
- [WebGPU Interop](#webgpu-interop)
|
|
47
|
-
- [Serialization Guide](#serialization-guide)
|
|
48
|
-
- [Migration Guides](#migration-guides)
|
|
49
|
-
- [For AI Agents](#for-ai-agents)
|
|
50
|
-
- [FAQ](#faq)
|
|
51
|
-
- [Caveats and Known Limitations](#caveats-and-known-limitations)
|
|
52
|
-
- [Contributing](#contributing)
|
|
53
|
-
- [Changelog](#changelog)
|
|
54
|
-
- [License](#license)
|
|
55
|
-
|
|
56
|
-
## Why aiecsjs?
|
|
57
|
-
|
|
58
|
-
- **Archetype-first storage** — entities sharing the same component set live in one contiguous table; queries walk straight `for` loops over parallel TypedArrays. Iteration is cache-friendly by construction.
|
|
59
|
-
- **First-class TypeScript** — typed `EntityId`, components, worlds, and queries with no manual generics. Iteration helpers pass the SoA column views positionally (`forEachEntityIndexed(w, q, (e, i, pos, vel) => …)`); the **column arguments are `any`-typed** today — statically tuple-typed columns are future work — while `e` (`EntityId`) and `i` (`number`) are fully typed.
|
|
60
|
-
- **AI-first documentation contract** — every public export has a stability tag and a `since` version. Ships `llms.txt`, `llms-full.txt`, and `api.json` so LLM tools can read the API surface directly.
|
|
61
|
-
|
|
62
|
-
### Comparison
|
|
63
|
-
|
|
64
|
-
| | aiecsjs 0.1 | bitECS 0.4 | miniplex 2.0 | becsy 0.15 |
|
|
65
|
-
|---|---|---|---|---|
|
|
66
|
-
| Storage | Archetype + SoA columns | SparseSet + bitmask + SoA/AoS | Archetype + JS objects | Configurable (packed/sparse/compact) + ArrayBuffer |
|
|
67
|
-
| API style | Functional + `pipe` | Functional + `pipe` | Chainable OO | Decorator classes |
|
|
68
|
-
| TS inference on query | Typed `e`/`i`; columns `any` | Manual | Predicate inference | Class-based |
|
|
69
|
-
| Multi-thread | SAB snapshot transport (0.x); true shared cols planned 0.3+ | SAB-ready, scheduling DIY | Single-thread | Roadmap (not shipped) |
|
|
70
|
-
| AI docs | `llms.txt` + `llms-full.txt` + `api.json` | No | No | No |
|
|
71
|
-
| Maintenance | Active (new) | Active | Slowed (~3y since npm release) | Active |
|
|
72
|
-
|
|
73
|
-
### When NOT to use aiecsjs
|
|
74
|
-
|
|
75
|
-
- **You need the tiniest possible bundle (≤ 3 kB).** Use [bitECS 0.4](https://github.com/NateTheGreatt/bitECS) — its SparseSet model is leaner and tree-shakes aggressively.
|
|
76
|
-
- **You want plain JS objects as entities with full DX freedom.** Use [miniplex](https://github.com/hmans/miniplex). It's the DX champion at the cost of a 2–4× iteration penalty.
|
|
77
|
-
- **You need automatic system scheduling with declared read/write entitlements.** Use [@lastolivegames/becsy](https://github.com/LastOliveGames/becsy). aiecsjs systems are just functions in `pipe()` order.
|
|
78
|
-
- **Your workload is entity-churn dominated (>50% of entities change shape per frame).** A sparse-set ECS will beat an archetype ECS here. Use bitECS or goodluck.
|
|
79
|
-
|
|
80
|
-
### What aiecsjs does NOT do
|
|
81
|
-
|
|
82
|
-
The core stays narrow on purpose. The following are explicit non-goals; reach for a dedicated tool or write app-layer code:
|
|
83
|
-
|
|
84
|
-
- **System scheduler with declared read/write entitlements.** `pipe()` runs systems in declared order. Use `@lastolivegames/becsy` if you need parallel scheduling.
|
|
85
|
-
- **Render component / scene-graph sync.** ECS holds data only. Pair with PixiJS, Three.js, or your renderer of choice.
|
|
86
|
-
- **Physics / spatial partition.** No broad-phase, no collision. Use Rapier, Matter, or a dedicated quadtree.
|
|
87
|
-
- **Network replication.** `aiecsjs/serialize` produces snapshot bytes; how they cross the wire is your app's choice.
|
|
88
|
-
- **Reactive value-predicate queries.** `enterQuery` / `exitQuery` fire on component-set membership change only. Component value mutations are not tracked.
|
|
89
|
-
- **Prefab / entity inheritance / hierarchy.** `aiecsjs/relations` provides plain entity-to-entity references, not inheritance.
|
|
90
|
-
|
|
91
|
-
## Integration with aibridgejs
|
|
92
|
-
|
|
93
|
-
If you stream world state across an [aibridgejs](https://www.npmjs.com/package/aibridgejs) bridge (iframe / Flutter InAppWebView), the bridge enforces a strict JSON envelope and silently drops `Date`, `Map`, `Set`, and class instances. AoS components from `defineObjectComponent(...)` can legally hold any of these; sending them as-is corrupts the payload on the host side.
|
|
94
|
-
|
|
95
|
-
Correct shape — serialise first, emit a plain object or byte array:
|
|
96
|
-
|
|
97
|
-
```ts
|
|
98
|
-
import { toJSON } from 'aiecsjs/serialize'
|
|
99
|
-
|
|
100
|
-
const snap = toJSON(world)
|
|
101
|
-
await bridge.emit('world.snapshot', snap)
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
Do NOT do — `getComponent` returns the live column view or the AoS instance with its prototype intact, which the bridge cannot transport:
|
|
105
|
-
|
|
106
|
-
```ts
|
|
107
|
-
await bridge.emit('inv', getComponent(world, eid, Inventory))
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
`serializeWorld(world)` (binary, `Uint8Array`) is also bridge-safe; wrap the bytes in a JSON envelope like `{ kind: 'binary', bytes: Array.from(snap) }`, or use a transferable channel when the host supports it.
|
|
5
|
+
> **Status: 0.5.8 - stable 1.0-track core.** Root ECS APIs are stable; worker transport remains adapter-shaped and environment-dependent.
|
|
111
6
|
|
|
112
7
|
## Install
|
|
113
8
|
|
|
114
9
|
```bash
|
|
115
|
-
npm install aiecsjs
|
|
116
10
|
pnpm add aiecsjs
|
|
117
|
-
yarn add aiecsjs
|
|
118
|
-
bun add aiecsjs
|
|
119
11
|
```
|
|
120
12
|
|
|
121
|
-
CDN (ESM):
|
|
122
|
-
|
|
123
|
-
```html
|
|
124
|
-
<script type="module">
|
|
125
|
-
import { createWorld } from 'https://unpkg.com/aiecsjs?module'
|
|
126
|
-
</script>
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
Peer requirements: **Node 18+** (for ESM and structured-clone WebStreams), **TypeScript 5.0+** (optional but recommended for the inference goodies).
|
|
130
|
-
|
|
131
|
-
## Quick Start
|
|
132
|
-
|
|
133
13
|
```ts
|
|
134
14
|
import {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const Lifetime = defineComponent({ remaining: Types.f32 })
|
|
144
|
-
|
|
145
|
-
const world = createWorld({ initialCapacity: 1024 })
|
|
146
|
-
|
|
147
|
-
for (let i = 0; i < 100; i++) {
|
|
148
|
-
const e = createEntity(world)
|
|
149
|
-
addComponent(world, e, Position, { x: Math.random() * 100, y: Math.random() * 100 })
|
|
150
|
-
addComponent(world, e, Velocity, { x: Math.random() * 2 - 1, y: Math.random() * 2 - 1 })
|
|
151
|
-
addComponent(world, e, Lifetime, { remaining: 5 })
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const movers = defineQuery([Position, Velocity])
|
|
155
|
-
const decaying = defineQuery([Lifetime])
|
|
156
|
-
|
|
157
|
-
const movementSystem = (w, dt) => {
|
|
158
|
-
forEachEntityIndexed(w, movers, (e, i, pos, vel) => {
|
|
159
|
-
pos.x[i] += vel.x[i] * dt // `i` is the safe column subscript
|
|
160
|
-
pos.y[i] += vel.y[i] * dt
|
|
161
|
-
})
|
|
162
|
-
return w
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const lifetimeSystem = (w, dt) => {
|
|
166
|
-
forEachEntityIndexed(w, decaying, (e, i, life) => {
|
|
167
|
-
life.remaining[i] -= dt
|
|
168
|
-
if (life.remaining[i] <= 0) destroyEntity(w, e) // destroyEntity takes the packed `e`
|
|
169
|
-
})
|
|
170
|
-
return w
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const tick = pipe(movementSystem, lifetimeSystem)
|
|
174
|
-
const loop = createLoop({ fixed: 1 / 60, onUpdate: (dt) => tick(world, dt) })
|
|
175
|
-
loop.start()
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
That's a complete simulation: 100 particles drifting until each one's lifetime expires.
|
|
179
|
-
|
|
180
|
-
## Core Concepts
|
|
181
|
-
|
|
182
|
-
**Entity.** A versioned 32-bit ID. The low bits are the entity index; the high bits are a generation counter that bumps when the ID is recycled. This prevents the "I cached a reference to entity 42 but now entity 42 is something else" class of bug. Default split is 24 index bits + 8 generation bits (≈ 16M entities × 256 recycles each). Because the packed ID is **not** the column index once a slot is recycled, prefer **`forEachEntityIndexed((e, i, ...cols) => …)`** for column iteration — it hands you the masked index `i` (the safe SoA subscript, `pos.x[i]`) alongside the packed `e`. For the raw `forEachEntity` form, derive the index with `getEntityIndex(e)`.
|
|
183
|
-
|
|
184
|
-
**Component.** A data type attached to entities. Two flavours:
|
|
185
|
-
- **SoA (Structure of Arrays)** — declared with `defineComponent({ x: Types.f32, y: Types.f32 })`. Each field becomes a TypedArray column indexed by entity ID. Best for hot, numeric data.
|
|
186
|
-
- **AoS (Array of Structures)** — declared with `defineObjectComponent(() => ({ ref: null }))`. Each entity gets its own JS object. Best for heterogeneous data or external references (e.g. a `three.js` Mesh).
|
|
187
|
-
|
|
188
|
-
**System.** Just a function: `(world, ctx) => world`. No base class, no decorators. Compose multiple systems with `pipe()`. The returned world is the same world reference — `pipe` is associative and the world is mutated in place.
|
|
189
|
-
|
|
190
|
-
**Query.** A persistent descriptor over component sets: `defineQuery({ all: [Position], any: [Active, Visible], none: [Hidden] })`. Queries are pre-compiled to a bitmask pair and cached in the world; iteration is O(matching archetypes), not O(entities).
|
|
191
|
-
|
|
192
|
-
**World.** Owns all entities, components, archetypes, and query indices. Multiple worlds are supported; they do not share entity IDs unless you opt-in by sharing a `SharedArrayBuffer`.
|
|
193
|
-
|
|
194
|
-
**Archetype.** An internal table — one per unique component combination present in the world. When an entity gains or loses a component, it migrates from one archetype to another. Migration cost scales with the number of component columns the entity has; iteration cost does not.
|
|
195
|
-
|
|
196
|
-
## Guide
|
|
197
|
-
|
|
198
|
-
### Defining components
|
|
199
|
-
|
|
200
|
-
```ts
|
|
201
|
-
// SoA: TypedArray-backed, max performance, SAB-safe
|
|
202
|
-
const Position = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
203
|
-
|
|
204
|
-
// SoA with a fixed-size vector field
|
|
205
|
-
const Transform = defineComponent({
|
|
206
|
-
position: [Types.f32, 3], // Float32Array per entity, length 3
|
|
207
|
-
scale: Types.f32,
|
|
208
|
-
})
|
|
209
|
-
|
|
210
|
-
// Tag: zero-byte marker, no data
|
|
211
|
-
const Player = defineTag()
|
|
212
|
-
const Dead = defineTag()
|
|
213
|
-
|
|
214
|
-
// AoS: arbitrary JS objects, main-thread only
|
|
215
|
-
const MeshRef = defineObjectComponent<{ mesh: THREE.Mesh | null }>(() => ({ mesh: null }))
|
|
216
|
-
```
|
|
217
|
-
|
|
218
|
-
### Spawning and destroying entities
|
|
219
|
-
|
|
220
|
-
```ts
|
|
221
|
-
const eid = createEntity(world)
|
|
222
|
-
addComponent(world, eid, Position, { x: 10, y: 20 })
|
|
223
|
-
addComponent(world, eid, Player)
|
|
224
|
-
|
|
225
|
-
if (entityExists(world, eid)) {
|
|
226
|
-
destroyEntity(world, eid)
|
|
227
|
-
}
|
|
15
|
+
Types,
|
|
16
|
+
addComponent,
|
|
17
|
+
createEntity,
|
|
18
|
+
createWorld,
|
|
19
|
+
defineComponent,
|
|
20
|
+
forEachEntity,
|
|
21
|
+
getComponent,
|
|
22
|
+
} from "aiecsjs";
|
|
228
23
|
```
|
|
229
24
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
### Writing systems
|
|
233
|
-
|
|
234
|
-
```ts
|
|
235
|
-
const moveSystem = (world: World, dt: number) => {
|
|
236
|
-
forEachEntityIndexed(world, defineQuery([Position, Velocity]), (e, i, pos, vel) => {
|
|
237
|
-
pos.x[i] += vel.x[i] * dt // `i` is the safe column subscript
|
|
238
|
-
pos.y[i] += vel.y[i] * dt
|
|
239
|
-
})
|
|
240
|
-
return world
|
|
241
|
-
}
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
**Prefer `forEachEntityIndexed` for column iteration.** Its callback is `(e, i, ...cols)`: index every SoA column with `i` (the masked slot index), and pass the packed `e` to entity operations (`destroyEntity`, `hasComponent`, command buffers). The yielded `i` is always the correct subscript, even after a slot is recycled — so you never hand-mask or call `getEntityIndex` in the loop. Use **`forEachEntity`** when you only need the `EntityId` (its first argument is a packed `EntityId`, not a column index; derive the subscript with `getEntityIndex(e)` if you must index columns). See [Core Concepts → Entity](#core-concepts) for why `e !== i` once a slot is recycled.
|
|
245
|
-
|
|
246
|
-
Hoist `defineQuery(...)` calls out of the hot loop — the same query object is returned for the same component set, but the lookup still costs a hash.
|
|
247
|
-
|
|
248
|
-
### Tags in mixed queries
|
|
249
|
-
|
|
250
|
-
Every component named in `defineQuery([...])` — **including tags** — occupies one callback slot, in declaration order. A tag has no storage, so its slot is passed as the literal `true`:
|
|
251
|
-
|
|
252
|
-
```ts
|
|
253
|
-
const Frozen = defineTag()
|
|
254
|
-
const q = defineQuery([Frozen, Position]) // tag first
|
|
255
|
-
|
|
256
|
-
// ❌ Wrong: `pos` binds to the tag slot (`true`); `pos.x` throws.
|
|
257
|
-
forEachEntityIndexed(world, q, (e, i, pos) => { /* pos === true */ })
|
|
258
|
-
|
|
259
|
-
// ✅ Correct: account for every slot. Tag arrives as `true`.
|
|
260
|
-
forEachEntityIndexed(world, q, (e, i, _frozen, pos) => {
|
|
261
|
-
pos.x[i] += 1
|
|
262
|
-
})
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
**Recommendation: put tags last** so data columns come first and the trailing `true` slots are easy to ignore:
|
|
266
|
-
|
|
267
|
-
```ts
|
|
268
|
-
const q = defineQuery([Position, Velocity, Frozen]) // tags last
|
|
269
|
-
forEachEntityIndexed(world, q, (e, i, pos, vel /* , _frozen */) => {
|
|
270
|
-
pos.x[i] += vel.x[i]
|
|
271
|
-
})
|
|
272
|
-
```
|
|
273
|
-
|
|
274
|
-
(Tags still filter membership either way — ordering only affects the callback's argument layout. The column-argument list is `any`-typed, so a mismatched binding is a runtime error, not a compile error.)
|
|
275
|
-
|
|
276
|
-
### Composing with pipe and createLoop
|
|
277
|
-
|
|
278
|
-
```ts
|
|
279
|
-
import { createLoop } from 'aiecsjs/loop'
|
|
280
|
-
|
|
281
|
-
const tick = pipe(inputSystem, physicsSystem, movementSystem, renderSystem)
|
|
282
|
-
|
|
283
|
-
const loop = createLoop({
|
|
284
|
-
fixed: 1 / 60,
|
|
285
|
-
maxSubSteps: 5,
|
|
286
|
-
onUpdate: (dt) => tick(world, dt),
|
|
287
|
-
onRender: (alpha) => renderInterpolated(world, alpha),
|
|
288
|
-
})
|
|
289
|
-
|
|
290
|
-
loop.start()
|
|
291
|
-
// later: loop.stop()
|
|
292
|
-
```
|
|
293
|
-
|
|
294
|
-
The accumulator pattern in `createLoop` is the canonical fixed-timestep model from `gafferongames.com` — physics is deterministic and decoupled from variable frame rate.
|
|
295
|
-
|
|
296
|
-
### Reactive queries (enter/exit)
|
|
297
|
-
|
|
298
|
-
```ts
|
|
299
|
-
const newlyDead = enterQuery(defineQuery([Dead]))
|
|
300
|
-
const noLongerDead = exitQuery(defineQuery([Dead]))
|
|
301
|
-
|
|
302
|
-
const reapSystem = (world) => {
|
|
303
|
-
forEachEntity(world, newlyDead, (e) => playDeathAnimation(e))
|
|
304
|
-
forEachEntity(world, noLongerDead, (e) => stopDeathAnimation(e))
|
|
305
|
-
return world
|
|
306
|
-
}
|
|
307
|
-
```
|
|
308
|
-
|
|
309
|
-
`enterQuery` yields only entities that newly match this frame; `exitQuery` yields only entities that left. Both are computed incrementally during structural changes — there's no per-frame scan.
|
|
310
|
-
|
|
311
|
-
> **Define reactive queries at module scope** (as above), before any update runs. A reactive query's enter/exit buffer is **armed lazily** — the world starts tracking a query's transitions the first time that query (or its `enterQuery`/`exitQuery` view) participates in a structural change or is read. Create/destroy events that happen *before* the first registration are not retroactively captured. Module-scope definitions register the query at import time, so the very first `tick` already observes transitions; queries created lazily inside a system may miss the events from the frame they were introduced.
|
|
312
|
-
|
|
313
|
-
### Observers
|
|
314
|
-
|
|
315
|
-
```ts
|
|
316
|
-
import { onAdd, onRemove, onSet } from 'aiecsjs/observers'
|
|
317
|
-
|
|
318
|
-
const stopAdd = onAdd(world, Position, (e) => console.log('positioned', e))
|
|
319
|
-
const stopRemove = onRemove(world, Player, (e) => console.log('un-playered', e))
|
|
320
|
-
const stopSet = onSet(world, Health, (e, val) => console.log('health set', e, val))
|
|
321
|
-
|
|
322
|
-
// Auto-unsubscribe via AbortSignal (since 0.2.0):
|
|
323
|
-
const ac = new AbortController()
|
|
324
|
-
onAdd(world, Position, (e) => trackEntity(e), { signal: ac.signal })
|
|
325
|
-
// later, abort once and all observers attached to this signal are removed
|
|
326
|
-
ac.abort()
|
|
327
|
-
|
|
328
|
-
// Or the returned unsubscribe — both are idempotent and may be combined:
|
|
329
|
-
stopAdd()
|
|
330
|
-
stopRemove()
|
|
331
|
-
stopSet()
|
|
332
|
-
```
|
|
333
|
-
|
|
334
|
-
Observers fire synchronously inside the mutation call. Use them for side effects that must happen at the exact moment of the change (debugging, replication). For batched UI updates, prefer reactive queries.
|
|
335
|
-
|
|
336
|
-
**`onSet` is a low-level mutation hook**, not a reactive value-predicate query. It fires after `setComponent(world, eid, comp, value)` when the component is already present on the entity — `addComponent` does NOT trigger `onSet` (use `onAdd` for that path; an `addComponent` followed by `setComponent` fires both, in that order). `enterQuery` / `exitQuery` respond to structural component-set changes only; if you need a reactive "value crossed threshold" view, layer that in app code on top of `onSet`.
|
|
337
|
-
|
|
338
|
-
### Command buffers — when and why
|
|
339
|
-
|
|
340
|
-
The golden rule: **add or remove components via a command buffer while iterating** — never mutate an iterated entity's component set inline. Adding or removing a component changes archetype membership mid-walk, which can skip or double-process entities. In-loop `destroyEntity` **is** safe (the iteration re-reads the live row count each step, so the callback never sees the reserved eid 0 and never a destroyed entity), but with two caveats: a surviving entity swapped into a freed row is **deferred to the next pass** (not visited again this pass), and iteration order after a destroy is not guaranteed. Use a command buffer to defer component mutations:
|
|
341
|
-
|
|
342
|
-
```ts
|
|
343
|
-
import { withCommandBuffer } from 'aiecsjs/commands'
|
|
344
|
-
|
|
345
|
-
const damageSystem = (world) => {
|
|
346
|
-
const dying = defineQuery([Health])
|
|
347
|
-
withCommandBuffer(world, (cb) => {
|
|
348
|
-
forEachEntityIndexed(world, dying, (e, i, health) => {
|
|
349
|
-
if (health.hp[i] <= 0) cb.destroy(e) // index the column with `i`, queue the packed `e`
|
|
350
|
-
})
|
|
351
|
-
}) // auto-flushes here
|
|
352
|
-
return world
|
|
353
|
-
}
|
|
354
|
-
```
|
|
355
|
-
|
|
356
|
-
Or manually:
|
|
357
|
-
|
|
358
|
-
```ts
|
|
359
|
-
import { createCommandBuffer, flush } from 'aiecsjs/commands'
|
|
360
|
-
|
|
361
|
-
const cb = createCommandBuffer(world)
|
|
362
|
-
forEachEntity(world, q, (e) => { cb.remove(e, SomeTag) })
|
|
363
|
-
flush(cb)
|
|
364
|
-
```
|
|
365
|
-
|
|
366
|
-
### Relations and hierarchies
|
|
367
|
-
|
|
368
|
-
> The Relations API is **stable since 0.4.0**. The graph API (`defineRelation` / `addRelation` / `removeRelation` / `getRelationTargets` / `getRelationData`) and the built-in `ChildOf` relation are frozen for the 1.x track.
|
|
369
|
-
|
|
370
|
-
```ts
|
|
371
|
-
import { defineRelation, addRelation, ChildOf, getRelationTargets, getRelationData } from 'aiecsjs/relations'
|
|
372
|
-
|
|
373
|
-
const Likes = defineRelation<{ since: number }>()
|
|
374
|
-
addRelation(world, alice, Likes, bob, { since: 2020 })
|
|
375
|
-
addRelation(world, alice, ChildOf, parent)
|
|
376
|
-
|
|
377
|
-
const parentOfAlice = getRelationTargets(world, alice, ChildOf)
|
|
378
|
-
const likedSince = getRelationData(world, alice, Likes, bob) // { since: 2020 }
|
|
379
|
-
```
|
|
380
|
-
|
|
381
|
-
Exclusive relations (one target only) and the `getRelationData` reader are stable as of 0.4.0. Wildcard relation queries and serialisation of relation graphs remain future work and are not part of the frozen surface.
|
|
382
|
-
|
|
383
|
-
## API Reference
|
|
384
|
-
|
|
385
|
-
Full machine-readable surface in [`api.json`](./api.json). Stability flags in [`STABILITY.md`](./STABILITY.md).
|
|
386
|
-
|
|
387
|
-
### World — `aiecsjs`
|
|
388
|
-
|
|
389
|
-
| Function | Signature | Stability |
|
|
390
|
-
|---|---|---|
|
|
391
|
-
| `createWorld` | `(options?: WorldOptions) => World` | stable |
|
|
392
|
-
| `disposeWorld` | `(world: World) => void` | stable (since 0.2.0) |
|
|
393
|
-
| `destroyWorld` | `(world: World) => void` | **deprecated** since 0.2.0 — alias of `disposeWorld`; scheduled for removal in 1.0 |
|
|
394
|
-
| `resetWorld` | `(world: World) => void` | stable |
|
|
395
|
-
| `getWorldSize` | `(world: World) => number` (alive count) | stable |
|
|
396
|
-
| `getWorldCapacity` | `(world: World) => number` | stable |
|
|
397
|
-
|
|
398
|
-
`WorldOptions`:
|
|
399
|
-
```ts
|
|
400
|
-
type WorldOptions = {
|
|
401
|
-
initialCapacity?: number // default 1024
|
|
402
|
-
maxEntities?: number // default 1_000_000
|
|
403
|
-
indexBits?: 20 | 24 // default 24 → 16M entities
|
|
404
|
-
generationBits?: 8 | 12 | 16 // default 8 → 256 recycles
|
|
405
|
-
buffer?: SharedArrayBuffer // RESERVED — no effect in 0.x (see note below)
|
|
406
|
-
bufferByteOffset?: number // RESERVED — paired with buffer; no effect in 0.x
|
|
407
|
-
}
|
|
408
|
-
```
|
|
409
|
-
|
|
410
|
-
> **`buffer` / `bufferByteOffset` are reserved and currently unimplemented.** Setting them has no effect: a world always allocates its own column storage and never reads a caller-supplied SAB. For Worker handoff use the snapshot-copy transport — post `transferableSnapshot(world)` and rebuild via `adoptSnapshot` from `aiecsjs/worker` (see [Multi-threading Guide](#multi-threading-guide)). The fields are kept for forward-compatibility with the true shared-column backing targeted for 0.3+.
|
|
411
|
-
|
|
412
|
-
### Entity — `aiecsjs`
|
|
413
|
-
|
|
414
|
-
| Function | Signature | Stability |
|
|
415
|
-
|---|---|---|
|
|
416
|
-
| `createEntity` | `(world: World) => EntityId` | stable |
|
|
417
|
-
| `destroyEntity` | `(world: World, eid: EntityId) => void` | stable |
|
|
418
|
-
| `entityExists` | `(world: World, eid: EntityId) => boolean` | stable |
|
|
419
|
-
| `getEntityIndex` | `(eid: EntityId) => number` | stable |
|
|
420
|
-
| `getEntityGeneration` | `(eid: EntityId) => number` | stable (since 0.3.0) — returns the 8-bit generation field; uses default 24/8 layout |
|
|
421
|
-
| `packEntity` | `(index: number, generation: number) => EntityId` | stable (since 0.3.0) — packs index + generation using default 24/8 layout |
|
|
422
|
-
| `refOf` | `<T>(world: World, eid: EntityId) => EntityRef<T>` | stable (since 0.3.0) — creates ABA-safe ref; throws `EntityNotAliveError` if entity is dead |
|
|
423
|
-
| `deref` | `<T>(world: World, ref: EntityRef<T>) => EntityId \| null` | stable (since 0.3.0) — returns live `EntityId` or `null` if stale/cross-world; never throws |
|
|
424
|
-
| `aliveRef` | `<T>(world: World, ref: EntityRef<T>) => boolean` | stable (since 0.3.0) — boolean guard form of `deref`; never throws |
|
|
425
|
-
| `EntityRef` | `interface EntityRef<T> { id: EntityId; worldId: number }` | stable (since 0.3.0) — opaque ABA-safe reference; in-memory only |
|
|
426
|
-
| `EntityNotAliveError` | `class EntityNotAliveError extends Error { eid: number }` | stable (since 0.3.0) — thrown by `refOf` when entity is not alive |
|
|
427
|
-
|
|
428
|
-
### Component — `aiecsjs`
|
|
429
|
-
|
|
430
|
-
| Function | Signature | Stability |
|
|
431
|
-
|---|---|---|
|
|
432
|
-
| `defineComponent` | `<S extends SoASchema>(schema: S) => SoAComponent<S>` | stable |
|
|
433
|
-
| `defineTag` | `() => TagComponent` | stable |
|
|
434
|
-
| `defineObjectComponent` | `<T>(factory?: () => T) => AoSComponent<T>` | stable |
|
|
435
|
-
| `addComponent` | `<C>(world, eid, c: C, init?) => void` | stable |
|
|
436
|
-
| `removeComponent` | `<C>(world, eid, c: C) => void` | stable |
|
|
437
|
-
| `hasComponent` | `<C>(world, eid, c: C) => boolean` | stable |
|
|
438
|
-
| `getComponent` | `<C>(world, eid, c: C) => ComponentView<C>` | stable |
|
|
439
|
-
| `setComponent` | `<C, V>(world, eid, c: C, v: V) => void` | stable |
|
|
440
|
-
|
|
441
|
-
`Types`:
|
|
442
|
-
```ts
|
|
443
|
-
const Types = { i8, u8, i16, u16, i32, u32, f32, f64, eid, bool } as const
|
|
444
|
-
```
|
|
445
|
-
|
|
446
|
-
### Query — `aiecsjs`
|
|
447
|
-
|
|
448
|
-
| Function | Signature | Stability |
|
|
449
|
-
|---|---|---|
|
|
450
|
-
| `defineQuery` | `(components: ComponentLike[] \| QueryDescriptor) => Query` | stable |
|
|
451
|
-
| `runQuery` | `(world: World, q: Query) => readonly EntityId[]` | stable |
|
|
452
|
-
| `forEachEntity` | `<Q>(world, q: Q, fn: (eid, ...cols) => void) => void` | stable |
|
|
453
|
-
| `forEachEntityIndexed` | `<Q>(world, q: Q, fn: (eid, i, ...cols) => void) => void` | stable |
|
|
454
|
-
| `iterQuery` | `(world, q) => IterableIterator<EntityId>` | stable |
|
|
455
|
-
| `enterQuery` | `(q: Query) => Query` | stable |
|
|
456
|
-
| `exitQuery` | `(q: Query) => Query` | stable |
|
|
457
|
-
| `queryArchetypes` | `(world, q) => readonly Archetype[]` | experimental |
|
|
458
|
-
|
|
459
|
-
### System — `aiecsjs`
|
|
460
|
-
|
|
461
|
-
| Function | Signature | Stability |
|
|
462
|
-
|---|---|---|
|
|
463
|
-
| `pipe` | `<W, Ctx>(...systems) => System<W, Ctx>` | stable |
|
|
464
|
-
| `System` (type) | `(world, ctx) => world` | stable |
|
|
465
|
-
|
|
466
|
-
### Loop — `aiecsjs/loop`
|
|
467
|
-
|
|
468
|
-
| Function | Signature | Stability |
|
|
469
|
-
|---|---|---|
|
|
470
|
-
| `createLoop` | `(opts) => { start(), stop() }` | stable |
|
|
471
|
-
|
|
472
|
-
### Command Buffer — `aiecsjs/commands`
|
|
473
|
-
|
|
474
|
-
| Function | Signature | Stability |
|
|
475
|
-
|---|---|---|
|
|
476
|
-
| `createCommandBuffer` | `(world) => CommandBuffer` | stable |
|
|
477
|
-
| `flush` | `(cb: CommandBuffer) => void` | stable |
|
|
478
|
-
| `withCommandBuffer` | `<R>(world, fn: (cb) => R) => R` | stable |
|
|
479
|
-
|
|
480
|
-
### Observers — `aiecsjs/observers`
|
|
481
|
-
|
|
482
|
-
| Function | Signature | Stability |
|
|
483
|
-
|---|---|---|
|
|
484
|
-
| `observe` | `(world, q, event, handler, opts?: { signal? }) => () => void` | stable |
|
|
485
|
-
| `onAdd` | `(world, comp, handler, opts?: { signal? }) => () => void` | stable |
|
|
486
|
-
| `onRemove` | `(world, comp, handler, opts?: { signal? }) => () => void` | stable |
|
|
487
|
-
| `onSet` | `(world, comp, handler, opts?: { signal? }) => () => void` | stable; low-level mutation hook, not reactive |
|
|
488
|
-
|
|
489
|
-
### Serialization — `aiecsjs/serialize`
|
|
490
|
-
|
|
491
|
-
| Function | Signature | Stability |
|
|
492
|
-
|---|---|---|
|
|
493
|
-
| `serializeWorld` | `(world, opts?) => Uint8Array` | stable |
|
|
494
|
-
| `deserializeWorld` | `(bytes, opts?) => World` | stable |
|
|
495
|
-
| `toJSON` | `(world) => WorldSnapshot` | stable |
|
|
496
|
-
| `fromJSON` | `(snap) => World` | stable |
|
|
497
|
-
| `createDeltaSerializer` | `(world, opts?) => DeltaSerializer` | experimental |
|
|
498
|
-
|
|
499
|
-
### Worker / SAB — `aiecsjs/worker`
|
|
500
|
-
|
|
501
|
-
| Function | Signature | Stability |
|
|
502
|
-
|---|---|---|
|
|
503
|
-
| `transferableSnapshot` | `(world) => { buffer, meta }` | experimental |
|
|
504
|
-
| `adoptSnapshot` | `(snap) => World` | experimental |
|
|
505
|
-
| `attachWorld` | `(buffer, opts?) => World` | experimental |
|
|
506
|
-
| `detachWorld` | `(world) => void` | experimental |
|
|
507
|
-
|
|
508
|
-
### Relations — `aiecsjs/relations`
|
|
509
|
-
|
|
510
|
-
| Function | Signature | Stability |
|
|
511
|
-
|---|---|---|
|
|
512
|
-
| `defineRelation` | `<T>(opts?) => Relation<T>` | stable |
|
|
513
|
-
| `addRelation` | `(world, src, rel, tgt, data?) => void` | stable |
|
|
514
|
-
| `removeRelation` | `(world, src, rel, tgt) => void` | stable |
|
|
515
|
-
| `getRelationTargets` | `(world, src, rel) => readonly EntityId[]` | stable |
|
|
516
|
-
| `getRelationData` | `<T>(world, src, rel, tgt) => T \| undefined` | stable (since 0.4.0) |
|
|
517
|
-
| `ChildOf` (constant) | `Relation` | stable |
|
|
518
|
-
|
|
519
|
-
### Utility — `aiecsjs`
|
|
520
|
-
|
|
521
|
-
| Export | Type | Stability |
|
|
522
|
-
|---|---|---|
|
|
523
|
-
| `VERSION` | `string` | stable |
|
|
524
|
-
| `IS_SAB_SUPPORTED` | `boolean` | stable |
|
|
525
|
-
| `isWorld` | `(x: unknown) => x is World` | stable |
|
|
526
|
-
| `isEntity` | `(world, x) => x is EntityId` | stable |
|
|
527
|
-
|
|
528
|
-
## Performance
|
|
529
|
-
|
|
530
|
-
### Storage model
|
|
531
|
-
|
|
532
|
-
```
|
|
533
|
-
World
|
|
534
|
-
├── Archetype 0: [] (empty entities)
|
|
535
|
-
├── Archetype 1: [Position]
|
|
536
|
-
│ ├── entities: Uint32Array [e1, e2, e3, ...]
|
|
537
|
-
│ └── columns: Position.x: Float32Array, Position.y: Float32Array
|
|
538
|
-
├── Archetype 2: [Position, Velocity]
|
|
539
|
-
│ ├── entities: Uint32Array [e4, e5, ...]
|
|
540
|
-
│ ├── columns: Position.x, Position.y, Velocity.x, Velocity.y
|
|
541
|
-
└── Archetype 3: [Position, Velocity, Health]
|
|
542
|
-
└── ...
|
|
543
|
-
```
|
|
544
|
-
|
|
545
|
-
A query for `(Position, Velocity)` matches archetypes 2 and 3 and walks each linearly. Each archetype's columns are contiguous `Float32Array`s — the JIT can vectorise the inner loop and the L1 cache hit rate is near 100%.
|
|
546
|
-
|
|
547
|
-
### Cost model
|
|
548
|
-
|
|
549
|
-
- **Iteration**: `O(matching archetypes × entities per archetype)` with effectively zero per-entity overhead after the archetype list is resolved. Resolution is amortised by query caching.
|
|
550
|
-
- **Add / remove component**: `O(component count on entity)`. The entity row is copied from its source archetype's columns into the destination's. If you flicker a tag every frame on N entities, this is N × (column count) memory moves per frame.
|
|
551
|
-
- **Query setup**: `O(component count)` at `defineQuery` time. Re-using the same component set returns the cached query.
|
|
552
|
-
|
|
553
|
-
### Tips
|
|
554
|
-
|
|
555
|
-
- Hoist `defineQuery` out of the hot loop. Same component set returns the same query object, but the lookup still costs a hash.
|
|
556
|
-
- Prefer **bulk operations**: spawn 1000 entities by calling `createEntity` + `addComponent` in a tight loop; the archetype migration runs once per shape.
|
|
557
|
-
- Group **frequently-toggled tags** into one stable component with a boolean field, instead of constantly adding/removing a tag — the latter triggers archetype migration.
|
|
558
|
-
- For very hot inner loops, fetch each column once at the top of the system: `const px = Position.x; const vx = Velocity.x;` then index directly.
|
|
559
|
-
|
|
560
|
-
### Reproducible micro-benchmark
|
|
561
|
-
|
|
562
|
-
```ts
|
|
563
|
-
import { createWorld, createEntity, addComponent, defineComponent, defineQuery, forEachEntityIndexed, Types } from 'aiecsjs'
|
|
564
|
-
|
|
565
|
-
const Position = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
566
|
-
const Velocity = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
567
|
-
|
|
568
|
-
const world = createWorld({ initialCapacity: 100_000 })
|
|
569
|
-
for (let i = 0; i < 100_000; i++) {
|
|
570
|
-
const e = createEntity(world)
|
|
571
|
-
addComponent(world, e, Position, { x: 0, y: 0 })
|
|
572
|
-
addComponent(world, e, Velocity, { x: 1, y: 1 })
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
const movers = defineQuery([Position, Velocity])
|
|
576
|
-
const start = performance.now()
|
|
577
|
-
for (let frame = 0; frame < 1000; frame++) {
|
|
578
|
-
forEachEntityIndexed(world, movers, (e, i, pos, vel) => {
|
|
579
|
-
pos.x[i] += vel.x[i]; pos.y[i] += vel.y[i]
|
|
580
|
-
})
|
|
581
|
-
}
|
|
582
|
-
console.log('ms per frame:', (performance.now() - start) / 1000)
|
|
583
|
-
```
|
|
584
|
-
|
|
585
|
-
### Disclaimer
|
|
586
|
-
|
|
587
|
-
These tips are derived from public ECS benchmarks (noctjs/ecs-benchmark, ddmills/js-ecs-benchmarks) and the peer-reviewed C++ comparison by Cox, Williams, Vickers, Ward, and Headleand (CGVC 2025, [DOI 10.2312/cgvc.20251224](https://doi.org/10.2312/cgvc.20251224)). In renderer-heavy applications, ECS overhead is typically 1–2% of frame time (as observed by Felix Z on Meta's Project Flowerbed) — so the practical win from picking aiecsjs over a slower ECS is small unless your simulation is the bottleneck. Pick the library whose DX matches your workload.
|
|
588
|
-
|
|
589
|
-
## Multi-threading Guide
|
|
590
|
-
|
|
591
|
-
aiecsjs hands a world to a Worker via a **transferable snapshot**: the world is serialized into a `SharedArrayBuffer` (a plain `ArrayBuffer` when SAB is unavailable) and the Worker reconstructs a fresh world from it. In 0.x this is a snapshot-**copy** transport, not true shared-memory column aliasing — the two worlds do not see each other's writes after the handoff. True shared columns are targeted for 0.3+ (see [`STABILITY.md`](./STABILITY.md), `aiecsjs/worker`).
|
|
592
|
-
|
|
593
|
-
### Capability detection
|
|
594
|
-
|
|
595
|
-
```ts
|
|
596
|
-
import { IS_SAB_SUPPORTED } from 'aiecsjs'
|
|
597
|
-
if (!IS_SAB_SUPPORTED) {
|
|
598
|
-
console.warn('SAB unavailable; check COOP/COEP headers')
|
|
599
|
-
}
|
|
600
|
-
```
|
|
601
|
-
|
|
602
|
-
In browsers, `SharedArrayBuffer` requires the page to be **cross-origin isolated**: serve with `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp`.
|
|
603
|
-
|
|
604
|
-
### Main thread
|
|
605
|
-
|
|
606
|
-
Post the whole `transferableSnapshot(world)` — it already carries `{ buffer, meta }`. Do **not** allocate your own SAB and pass it to `createWorld`; `WorldOptions.buffer` is reserved and currently has no effect (see [Entity — `aiecsjs`](#entity--aiecsjs)).
|
|
607
|
-
|
|
608
|
-
```ts
|
|
609
|
-
import { createWorld } from 'aiecsjs'
|
|
610
|
-
import { transferableSnapshot } from 'aiecsjs/worker'
|
|
611
|
-
|
|
612
|
-
const world = createWorld()
|
|
613
|
-
|
|
614
|
-
// populate world...
|
|
615
|
-
|
|
616
|
-
const worker = new Worker(new URL('./sim-worker.ts', import.meta.url), { type: 'module' })
|
|
617
|
-
worker.postMessage(transferableSnapshot(world))
|
|
618
|
-
```
|
|
619
|
-
|
|
620
|
-
### Worker thread
|
|
621
|
-
|
|
622
|
-
`adoptSnapshot` is imported from the **`aiecsjs/worker`** sub-path (it is not exported from the root entry).
|
|
623
|
-
|
|
624
|
-
```ts
|
|
625
|
-
// sim-worker.ts
|
|
626
|
-
import { defineComponent, defineQuery, forEachEntityIndexed, Types } from 'aiecsjs'
|
|
627
|
-
import { adoptSnapshot } from 'aiecsjs/worker'
|
|
628
|
-
|
|
629
|
-
const Position = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
630
|
-
const Velocity = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
631
|
-
|
|
632
|
-
self.onmessage = (msg) => {
|
|
633
|
-
const world = adoptSnapshot(msg.data)
|
|
634
|
-
const movers = defineQuery([Position, Velocity])
|
|
635
|
-
setInterval(() => {
|
|
636
|
-
forEachEntityIndexed(world, movers, (e, i, pos, vel) => {
|
|
637
|
-
pos.x[i] += vel.x[i]
|
|
638
|
-
pos.y[i] += vel.y[i]
|
|
639
|
-
})
|
|
640
|
-
}, 16)
|
|
641
|
-
}
|
|
642
|
-
```
|
|
643
|
-
|
|
644
|
-
### Atomics and synchronisation
|
|
645
|
-
|
|
646
|
-
Reads and writes to TypedArray columns inside a SAB are **not atomic by default**. For most game-loop work, the convention is: one writer thread per column (e.g. physics worker owns positions), readers see eventually-consistent data. If you need strict ordering, use `Atomics.load` / `Atomics.store`; you give up vectorisation in exchange.
|
|
647
|
-
|
|
648
|
-
### Pitfalls
|
|
649
|
-
|
|
650
|
-
- **AoS components are NOT SAB-shareable.** Workers see only SoA columns. Either keep AoS data on the main thread or replace with SoA equivalents.
|
|
651
|
-
- **`createEntity` / `destroyEntity` from a Worker requires the worker to own the entity index.** Currently, attach worlds with `{ readOnly: true }` when the Worker should only mutate columns.
|
|
652
|
-
- **No synchronisation primitives are baked into aiecsjs.** Use `Atomics.wait` / `Atomics.notify` yourself if you need barriers.
|
|
653
|
-
|
|
654
|
-
## WebGPU Interop
|
|
655
|
-
|
|
656
|
-
A SoA component's columns are TypedArrays — exactly the format `GPUQueue.writeBuffer` accepts. There is no "ECS on GPU" mode; the integration is one-directional (CPU writes, GPU reads).
|
|
657
|
-
|
|
658
|
-
```ts
|
|
659
|
-
const Position = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
660
|
-
// after populating the world ...
|
|
661
|
-
|
|
662
|
-
const gpuBuffer = device.createBuffer({
|
|
663
|
-
size: Position.x.byteLength,
|
|
664
|
-
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
665
|
-
})
|
|
666
|
-
|
|
667
|
-
// upload every frame, or only when archetypes change
|
|
668
|
-
device.queue.writeBuffer(gpuBuffer, 0, Position.x)
|
|
669
|
-
```
|
|
670
|
-
|
|
671
|
-
### Caveats
|
|
672
|
-
|
|
673
|
-
- **Archetype migration invalidates column references.** If an entity moves to a new archetype, `Position.x` now points to a different `Float32Array` for that entity. For stable GPU buffers, dedicate a single archetype to entities you upload (e.g. tag them with a `Renderable` component that never gets removed) or upload per-archetype.
|
|
674
|
-
- **Write-back from GPU to ECS is not supported.** Read-only on the GPU side. If you need GPU-computed values back in CPU columns, map the buffer manually and write into the column.
|
|
675
|
-
- **Non-goal: running ECS systems on the GPU.** aiecsjs does not generate compute shaders from systems. Use a dedicated GPU compute framework for that.
|
|
676
|
-
|
|
677
|
-
## Serialization Guide
|
|
678
|
-
|
|
679
|
-
### Binary save/load
|
|
680
|
-
|
|
681
|
-
```ts
|
|
682
|
-
import { serializeWorld, deserializeWorld } from 'aiecsjs/serialize'
|
|
683
|
-
|
|
684
|
-
const bytes = serializeWorld(world)
|
|
685
|
-
localStorage.setItem('save', btoa(String.fromCharCode(...bytes)))
|
|
686
|
-
|
|
687
|
-
const restored = deserializeWorld(Uint8Array.from(atob(localStorage.getItem('save')!), c => c.charCodeAt(0)))
|
|
688
|
-
```
|
|
689
|
-
|
|
690
|
-
The binary format is **version-stamped**. Loading bytes from an older `aiecsjs` version returns a world if migration succeeds, throws otherwise. AoS components are stored as JSON inside the binary blob.
|
|
691
|
-
|
|
692
|
-
### JSON save/load
|
|
693
|
-
|
|
694
|
-
```ts
|
|
695
|
-
import { toJSON, fromJSON } from 'aiecsjs/serialize'
|
|
696
|
-
|
|
697
|
-
const snap = toJSON(world) // human-readable
|
|
698
|
-
const restored = fromJSON(snap)
|
|
699
|
-
```
|
|
700
|
-
|
|
701
|
-
Slower and larger than binary, but inspectable in DevTools.
|
|
702
|
-
|
|
703
|
-
### Network delta
|
|
704
|
-
|
|
705
|
-
For multiplayer, you want to send only what changed since last tick:
|
|
706
|
-
|
|
707
|
-
```ts
|
|
708
|
-
import { createDeltaSerializer } from 'aiecsjs/serialize'
|
|
709
|
-
|
|
710
|
-
const delta = createDeltaSerializer(world, { components: [Position, Velocity, Health] })
|
|
711
|
-
setInterval(() => {
|
|
712
|
-
const bytes = delta.capture()
|
|
713
|
-
ws.send(bytes)
|
|
714
|
-
}, 50)
|
|
715
|
-
|
|
716
|
-
// on the other side:
|
|
717
|
-
const remoteDelta = createDeltaSerializer(remoteWorld)
|
|
718
|
-
ws.onmessage = (e) => remoteDelta.apply(remoteWorld, new Uint8Array(e.data))
|
|
719
|
-
```
|
|
720
|
-
|
|
721
|
-
> ⚠️ `createDeltaSerializer` is `experimental` in 0.1; the wire format may change before 1.0.
|
|
722
|
-
|
|
723
|
-
## Migration Guides
|
|
724
|
-
|
|
725
|
-
Full tables in [`docs/MIGRATION.md`](https://github.com/islumina/aiecsjs/blob/main/docs/MIGRATION.md).
|
|
726
|
-
|
|
727
|
-
### From bitECS 0.4
|
|
728
|
-
|
|
729
|
-
| bitECS | aiecsjs |
|
|
730
|
-
|---|---|
|
|
731
|
-
| `createWorld()` | `createWorld()` |
|
|
732
|
-
| `defineComponent({ x: Types.f32 })` | `defineComponent({ x: Types.f32 })` |
|
|
733
|
-
| `addComponent(world, Comp, eid)` | `addComponent(world, eid, Comp, init?)` (arg order!) |
|
|
734
|
-
| `removeComponent(world, Comp, eid)` | `removeComponent(world, eid, Comp)` |
|
|
735
|
-
| `defineQuery([Comp])(world)` | `forEachEntity(world, defineQuery([Comp]), fn)` |
|
|
736
|
-
| `enterQuery(query)` | `enterQuery(defineQuery([...]))` (no `world` arg) |
|
|
737
|
-
| `pipe(s1, s2)(world)` | `pipe(s1, s2)(world, ctx)` (ctx threaded through) |
|
|
738
|
-
|
|
739
|
-
Key mental shift: aiecsjs is **archetype-first**. Tag flicker (adding/removing a tag every frame) is more expensive than in bitECS. Group toggleable state into boolean fields instead.
|
|
740
|
-
|
|
741
|
-
### From miniplex
|
|
742
|
-
|
|
743
|
-
| miniplex | aiecsjs |
|
|
744
|
-
|---|---|
|
|
745
|
-
| `world.add({ position: {x, y}, velocity: {x, y} })` | `createEntity` + `addComponent` (per component) |
|
|
746
|
-
| `world.with('position', 'velocity')` | `defineQuery([Position, Velocity])` |
|
|
747
|
-
| `for (const e of query)` | `forEachEntity(world, query, fn)` |
|
|
748
|
-
| `world.remove(entity)` | `destroyEntity(world, eid)` |
|
|
749
|
-
| `world.queue.add(...)` | `withCommandBuffer(world, cb => cb.create() ...)` |
|
|
750
|
-
|
|
751
|
-
Mental shift: components are **declared up front** in aiecsjs, not anonymous object shapes. The win is TypedArray performance + multi-thread compatibility.
|
|
752
|
-
|
|
753
|
-
### From ECSY
|
|
754
|
-
|
|
755
|
-
ECSY is [archived](https://github.com/ecsyjs/ecsy) as of April 2025. Migration is straightforward because both are archetype-style.
|
|
756
|
-
|
|
757
|
-
| ECSY | aiecsjs |
|
|
758
|
-
|---|---|
|
|
759
|
-
| `class C extends Component { static schema = { x: Types.Number } }` | `defineComponent({ x: Types.f32 })` |
|
|
760
|
-
| `class S extends System { execute(dt) { this.queries.foo.results.forEach(...) } }` | `const S = (world, dt) => { forEachEntity(world, foo, fn); return world }` |
|
|
761
|
-
| `world.registerComponent(C)` | (implicit on `defineComponent`) |
|
|
762
|
-
| `world.registerSystem(S)` then `world.execute(dt)` | `const tick = pipe(S1, S2); tick(world, dt)` |
|
|
763
|
-
|
|
764
|
-
## For AI Agents
|
|
765
|
-
|
|
766
|
-
This section is designed to be loaded as context by AI coding assistants. The same information is available in machine-readable form in [`llms.txt`](./llms.txt), [`llms-full.txt`](./llms-full.txt), and [`api.json`](./api.json).
|
|
767
|
-
|
|
768
|
-
### Decision matrix
|
|
769
|
-
|
|
770
|
-
| If you need... | Use aiecsjs | Use this instead |
|
|
771
|
-
|-------------------------------------------------|------------------|--------------------------|
|
|
772
|
-
| Fastest iteration over 10k+ entities | ✅ | — |
|
|
773
|
-
| Plain JS object entities, no typed schema | ❌ | miniplex |
|
|
774
|
-
| Automatic system scheduling / parallelism | ❌ (v0.1) | becsy |
|
|
775
|
-
| SAB-based main+worker setup | ✅ | — |
|
|
776
|
-
| Hot reload, frequent entity churn (>50%/frame) | works but slower | bitECS 0.4 (SparseSet) |
|
|
777
|
-
| Tiny bundle (< 3 kB) | ❌ | bitECS 0.4 |
|
|
778
|
-
| TypeScript-first inference | ✅ | — |
|
|
779
|
-
|
|
780
|
-
### Common patterns (copy-paste)
|
|
781
|
-
|
|
782
|
-
**1. Spawn-and-move**
|
|
783
|
-
|
|
784
|
-
```ts
|
|
785
|
-
import { createWorld, createEntity, addComponent, defineComponent, defineQuery, forEachEntityIndexed, pipe, Types } from 'aiecsjs'
|
|
786
|
-
|
|
787
|
-
const Position = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
788
|
-
const Velocity = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
789
|
-
|
|
790
|
-
const world = createWorld()
|
|
791
|
-
for (let i = 0; i < 1000; i++) {
|
|
792
|
-
const e = createEntity(world)
|
|
793
|
-
addComponent(world, e, Position, { x: i, y: 0 })
|
|
794
|
-
addComponent(world, e, Velocity, { x: 0, y: 1 })
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
const movers = defineQuery([Position, Velocity])
|
|
798
|
-
const move = (w, dt) => {
|
|
799
|
-
forEachEntityIndexed(w, movers, (e, i, p, v) => { p.x[i] += v.x[i] * dt; p.y[i] += v.y[i] * dt })
|
|
800
|
-
return w
|
|
801
|
-
}
|
|
802
|
-
pipe(move)(world, 0.016)
|
|
803
|
-
```
|
|
804
|
-
|
|
805
|
-
**2. Reactive UI via enter/exit query**
|
|
806
|
-
|
|
807
|
-
```ts
|
|
808
|
-
const visible = defineQuery([Renderable])
|
|
809
|
-
const becameVisible = enterQuery(visible)
|
|
810
|
-
const becameHidden = exitQuery(visible)
|
|
811
|
-
|
|
812
|
-
const renderSync = (world) => {
|
|
813
|
-
forEachEntity(world, becameVisible, (e) => domLayer.mount(e))
|
|
814
|
-
forEachEntity(world, becameHidden, (e) => domLayer.unmount(e))
|
|
815
|
-
return world
|
|
816
|
-
}
|
|
817
|
-
```
|
|
818
|
-
|
|
819
|
-
**3. Command buffer for safe deferred ops**
|
|
820
|
-
|
|
821
|
-
```ts
|
|
822
|
-
import { withCommandBuffer } from 'aiecsjs/commands'
|
|
823
|
-
|
|
824
|
-
const reapDead = (world) => {
|
|
825
|
-
withCommandBuffer(world, (cb) => {
|
|
826
|
-
forEachEntity(world, deadQ, (e) => cb.destroy(e))
|
|
827
|
-
})
|
|
828
|
-
return world
|
|
829
|
-
}
|
|
830
|
-
```
|
|
831
|
-
|
|
832
|
-
**4. SAB worker handoff**
|
|
833
|
-
|
|
834
|
-
```ts
|
|
835
|
-
// main.ts
|
|
836
|
-
const buffer = new SharedArrayBuffer(16 * 1024 * 1024)
|
|
837
|
-
const world = createWorld({ buffer })
|
|
838
|
-
const worker = new Worker(new URL('./physics.ts', import.meta.url), { type: 'module' })
|
|
839
|
-
worker.postMessage(transferableSnapshot(world))
|
|
840
|
-
|
|
841
|
-
// physics.ts
|
|
842
|
-
import { adoptSnapshot } from 'aiecsjs/worker'
|
|
843
|
-
self.onmessage = (e) => {
|
|
844
|
-
const world = adoptSnapshot(e.data)
|
|
845
|
-
// ... iterate columns
|
|
846
|
-
}
|
|
847
|
-
```
|
|
848
|
-
|
|
849
|
-
**5. Networked delta replay**
|
|
850
|
-
|
|
851
|
-
```ts
|
|
852
|
-
import { createDeltaSerializer } from 'aiecsjs/serialize'
|
|
853
|
-
|
|
854
|
-
const tx = createDeltaSerializer(world, { components: [Position, Velocity] })
|
|
855
|
-
setInterval(() => ws.send(tx.capture()), 50)
|
|
856
|
-
|
|
857
|
-
// remote
|
|
858
|
-
const rx = createDeltaSerializer(remoteWorld)
|
|
859
|
-
ws.onmessage = (e) => rx.apply(remoteWorld, new Uint8Array(e.data))
|
|
860
|
-
```
|
|
861
|
-
|
|
862
|
-
### Anti-patterns
|
|
863
|
-
|
|
864
|
-
1. **Mutating a `getComponent()` return value after the entity changes archetype.** The returned view points into the old archetype's TypedArray; it no longer represents this entity. Always re-fetch.
|
|
865
|
-
2. **Adding or removing components during `forEachEntity` without a command buffer.** May skip or double-process entities. Use `withCommandBuffer`.
|
|
866
|
-
3. **Holding `EntityId` across `destroyEntity`.** The ID may be recycled with a new generation. Always `entityExists(world, eid)` first.
|
|
867
|
-
4. **Using AoS components inside a SAB-backed Worker world.** AoS storage is main-thread only. Replace with SoA.
|
|
868
|
-
5. **Storing column references in closures longer than one frame.** Archetype migration replaces the TypedArray reference for an entity. Re-fetch each frame.
|
|
869
|
-
6. **Calling `addComponent(world, Comp, eid)` (bitECS order).** aiecsjs is `(world, eid, Comp, init?)`. Different positional args.
|
|
870
|
-
|
|
871
|
-
### Stable invariants
|
|
872
|
-
|
|
873
|
-
- `pipe(a, b, c)(world, ctx) === c(b(a(world, ctx), ctx), ctx)` — pipe is associative.
|
|
874
|
-
- `pipe(...)` always returns the same `World` reference (mutations in place).
|
|
875
|
-
- `defineQuery(X)` returns the same `Query` object for the same component set in the same module.
|
|
876
|
-
- Entity ID `0` is reserved. `createEntity` never returns `0`.
|
|
877
|
-
- `VERSION` exported from `'aiecsjs'` equals the published npm version.
|
|
878
|
-
- SoA columns are TypedArrays indexed by the entity **index** (always in `[0, getWorldCapacity(world))`), never by the packed `eid` — the two differ once a slot is recycled. `forEachEntityIndexed` yields this index as its `i` argument (correct for any world layout). For the raw `forEachEntity` form on the default world layout (`indexBits: 24`) the index is `getEntityIndex(eid)`; for a world created with a non-default `indexBits`, `getEntityIndex` uses the default 24-bit mask and is **not** the column index — prefer `forEachEntityIndexed` (or `EntityRef` + `deref`) instead.
|
|
879
|
-
- Component identity is **global** (created by `defineComponent`), but each component's storage is **per-world**.
|
|
880
|
-
|
|
881
|
-
### Glossary
|
|
882
|
-
|
|
883
|
-
- **Archetype** — a unique combination of components; entities sharing components live in the same archetype table.
|
|
884
|
-
- **SoA (Structure of Arrays)** — each component field is a separate TypedArray column. Default and preferred for hot data.
|
|
885
|
-
- **AoS (Array of Structures)** — each component instance is a plain JS object. For heterogeneous or rarely-touched data.
|
|
886
|
-
- **Bitmask** — a `Uint32Array` where each bit position represents one component; queries match by bitwise AND.
|
|
887
|
-
- **Command buffer** — a queue of pending structural mutations applied at a defined sync point.
|
|
888
|
-
- **Generation** — a counter incremented when an entity ID is recycled; prevents dangling references.
|
|
889
|
-
|
|
890
|
-
### Runtime version detection
|
|
25
|
+
## Quick Start
|
|
891
26
|
|
|
892
27
|
```ts
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
// running an experimental version; expect API drift in 0.x
|
|
896
|
-
}
|
|
897
|
-
```
|
|
898
|
-
|
|
899
|
-
### Stability contract
|
|
900
|
-
|
|
901
|
-
See [`STABILITY.md`](./STABILITY.md). In short: imports from `aiecsjs` (root) are **stable** within 0.x minors. `aiecsjs/relations` is **stable** since 0.4.0; `aiecsjs/worker` is **experimental**. Anything in `aiecsjs/internal` is **internal** — do not import.
|
|
902
|
-
|
|
903
|
-
### Telemetry / privacy
|
|
904
|
-
|
|
905
|
-
aiecsjs ships **no telemetry**, **no network calls**, **no postinstall scripts**. Verify with `npm pack --dry-run` and inspect the tarball.
|
|
906
|
-
|
|
907
|
-
### Citation for AI-generated code
|
|
28
|
+
const Position = defineComponent({ x: Types.f32, y: Types.f32 });
|
|
29
|
+
const Velocity = defineComponent({ x: Types.f32, y: Types.f32 });
|
|
908
30
|
|
|
909
|
-
|
|
31
|
+
const world = createWorld({ initialCapacity: 1024 });
|
|
32
|
+
const e = createEntity(world);
|
|
33
|
+
addComponent(world, e, Position, { x: 0, y: 0 });
|
|
34
|
+
addComponent(world, e, Velocity, { x: 1, y: 0 });
|
|
910
35
|
|
|
911
|
-
|
|
912
|
-
|
|
36
|
+
forEachEntity(world, [Position, Velocity], (entity) => {
|
|
37
|
+
const pos = getComponent(world, entity, Position);
|
|
38
|
+
const vel = getComponent(world, entity, Velocity);
|
|
39
|
+
pos.x += vel.x;
|
|
40
|
+
pos.y += vel.y;
|
|
41
|
+
});
|
|
913
42
|
```
|
|
914
43
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
- **aiecsjs is NOT bitECS.** Argument order for `addComponent` differs: aiecsjs uses `(world, eid, Component, init?)`; bitECS uses `(world, Component, eid)`.
|
|
918
|
-
- **Index SoA columns with the masked index, NOT the packed `EntityId`.** The footgun is closed in code: prefer **`forEachEntityIndexed(w, q, (e, i, ...cols) => …)`**, which yields the masked slot index `i` (the correct subscript: `pos.x[i]`) alongside the packed `e`. The packed `e` is **not** a column index — it only equals the index while no slot has been recycled (generation 0); after any `destroyEntity` it reads the wrong/out-of-bounds slot. With the raw `forEachEntity` form, derive the index yourself: hoist `const i = getEntityIndex(e)` once per callback. Either way, use `e` for entity operations (`destroyEntity(w, e)`, `hasComponent`, `getComponent`, command buffers).
|
|
919
|
-
- **A tag occupies a callback slot too.** `defineQuery([Tag, Position])` calls the callback as `(e, i, true, posCols)` under `forEachEntityIndexed` (`(e, true, posCols)` under `forEachEntity`) — the tag is passed as the literal `true`. Put tags **last** in the array (`defineQuery([Position, Tag])`) so data columns come first; or accept and ignore the trailing `true`. Binding `pos` to a tag slot makes `pos === true`, and `pos.x` throws.
|
|
920
|
-
- **`forEachEntity` / `forEachEntityIndexed` are the fast path.** `runQuery` allocates an array; `for...of iterQuery(...)` allocates an iterator. In hot loops, use `forEachEntityIndexed` (or `forEachEntity` when you only need the `EntityId`).
|
|
921
|
-
- **`defineObjectComponent` factory runs ONCE at definition**, not per entity. Mutate the entity's instance via `setComponent` / `getComponent`.
|
|
922
|
-
- **The component reference is the storage handle.** `Position` is not a constructor — it's a value object that aiecsjs uses to address the right archetype columns.
|
|
923
|
-
|
|
924
|
-
## FAQ
|
|
925
|
-
|
|
926
|
-
**Q: Is aiecsjs production-ready?**
|
|
927
|
-
A: Not yet. 0.x is experimental. The API surface in `STABILITY.md` is the working contract; expect bug fixes. Target 1.0 is post-implementation hardening.
|
|
928
|
-
|
|
929
|
-
**Q: Can I use class instances as components?**
|
|
930
|
-
A: Yes, with `defineObjectComponent`. But AoS components are main-thread only and slower than SoA in iteration.
|
|
931
|
-
|
|
932
|
-
**Q: How many components can I have?**
|
|
933
|
-
A: aiecsjs uses multi-word bitmasks; the practical limit is set by `WorldOptions.maxComponents` (default 256). Raise it if needed.
|
|
934
|
-
|
|
935
|
-
**Q: Does aiecsjs support hot reload?**
|
|
936
|
-
A: Component identities are module-scoped. If you re-import a module under HMR, the component identity changes; the safe path is to call `resetWorld(world)` and re-spawn.
|
|
937
|
-
|
|
938
|
-
**Q: Why not a class-based API?**
|
|
939
|
-
A: Functional API tree-shakes better, has lower overhead, and is what LLMs reliably generate. The trade-off (no automatic scheduling) is acceptable for the target audience.
|
|
940
|
-
|
|
941
|
-
**Q: Why isn't `aiecsjs` available on npm yet?**
|
|
942
|
-
A: It will be on first stable publish. Until then, the docs are the contract.
|
|
44
|
+
Use `defineTag()` for marker components and `defineObjectComponent()` when you need object references instead of TypedArray storage.
|
|
943
45
|
|
|
944
|
-
##
|
|
46
|
+
## Public Surface
|
|
945
47
|
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
48
|
+
| Import | Purpose |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| `aiecsjs` | World/entity/component/query/system helpers, `Types`, refs, errors, `VERSION`. |
|
|
51
|
+
| `aiecsjs/loop` | `createLoop()` for fixed-step style loops. |
|
|
52
|
+
| `aiecsjs/commands` | `createCommandBuffer()`, `flush()`, `withCommandBuffer()` for deferred structural changes. |
|
|
53
|
+
| `aiecsjs/observers` | `onAdd`, `onRemove`, `onSet`, `observe`. |
|
|
54
|
+
| `aiecsjs/serialize` | Binary/JSON world snapshots and delta serializer. |
|
|
55
|
+
| `aiecsjs/worker` | Transfer/adopt/attach helpers for worker snapshots. |
|
|
56
|
+
| `aiecsjs/relations` | `defineRelation`, `ChildOf`, relation add/remove/read helpers. |
|
|
953
57
|
|
|
954
|
-
##
|
|
58
|
+
## Sharp Edges
|
|
955
59
|
|
|
956
|
-
|
|
60
|
+
- Structural mutation during a query loop is allowed by the library, but app systems should prefer `withCommandBuffer()` when adding/removing/destroying entities from inside iteration.
|
|
61
|
+
- Reactive query buffers are unbounded until drained. Poll and clear them every frame or event tick.
|
|
62
|
+
- Query registration currently uses a global module cache; many worlds/components can make structural changes scan more query metadata than expected.
|
|
63
|
+
- Exclusive relation cleanup scans relation capacity on destroy. Large sparse relation tables can make destroy cost visible.
|
|
64
|
+
- Serialization restores capacity with safety clamps, but snapshots from untrusted sources should still be treated as hostile input.
|
|
65
|
+
- Worker/SAB helpers depend on the runtime environment. Feature-detect `SharedArrayBuffer` and cross-origin isolation in browsers.
|
|
66
|
+
- `pnpm lint` currently reports many `noExplicitAny` warnings. They are not release-blocking, but they add AI-review noise.
|
|
957
67
|
|
|
958
|
-
##
|
|
68
|
+
## AI Context
|
|
959
69
|
|
|
960
|
-
|
|
70
|
+
- Short index: [`llms.txt`](llms.txt)
|
|
71
|
+
- Full generated context: [`llms-full.txt`](llms-full.txt)
|
|
72
|
+
- Stability contract: [`STABILITY.md`](STABILITY.md)
|
|
73
|
+
- Current review backlog: [`REVIEW.md`](REVIEW.md)
|
|
74
|
+
- Machine-readable API: [`api.json`](api.json)
|
|
75
|
+
- Release history: [`CHANGELOG.md`](CHANGELOG.md)
|
|
961
76
|
|
|
962
77
|
## License
|
|
963
78
|
|
|
964
|
-
|
|
79
|
+
MIT
|