@vworlds/vecs 1.0.10 → 1.0.12

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.
Files changed (49) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/README.md +299 -228
  3. package/dist/command.d.ts +1 -46
  4. package/dist/component.d.ts +51 -59
  5. package/dist/component.js +31 -25
  6. package/dist/component.js.map +1 -1
  7. package/dist/dsl.d.ts +34 -26
  8. package/dist/dsl.js +46 -20
  9. package/dist/dsl.js.map +1 -1
  10. package/dist/entity.d.ts +96 -106
  11. package/dist/entity.js +261 -190
  12. package/dist/entity.js.map +1 -1
  13. package/dist/filter.d.ts +31 -23
  14. package/dist/filter.js +24 -17
  15. package/dist/filter.js.map +1 -1
  16. package/dist/index.d.ts +2 -1
  17. package/dist/index.js +1 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/package.json +3 -1
  20. package/dist/phase.d.ts +12 -30
  21. package/dist/phase.js +11 -10
  22. package/dist/phase.js.map +1 -1
  23. package/dist/query.d.ts +107 -144
  24. package/dist/query.js +200 -169
  25. package/dist/query.js.map +1 -1
  26. package/dist/system.d.ts +170 -86
  27. package/dist/system.js +253 -114
  28. package/dist/system.js.map +1 -1
  29. package/dist/timer.d.ts +50 -0
  30. package/dist/timer.js +154 -0
  31. package/dist/timer.js.map +1 -0
  32. package/dist/util/array_map.d.ts +4 -55
  33. package/dist/util/array_map.js +35 -37
  34. package/dist/util/array_map.js.map +1 -1
  35. package/dist/util/bitset.d.ts +40 -50
  36. package/dist/util/bitset.js +76 -62
  37. package/dist/util/bitset.js.map +1 -1
  38. package/dist/util/events.d.ts +14 -18
  39. package/dist/util/events.js +24 -3
  40. package/dist/util/events.js.map +1 -1
  41. package/dist/util/ordered_set.d.ts +1 -17
  42. package/dist/util/ordered_set.js +74 -25
  43. package/dist/util/ordered_set.js.map +1 -1
  44. package/dist/world.d.ts +230 -218
  45. package/dist/world.js +422 -327
  46. package/dist/world.js.map +1 -1
  47. package/eslint-rules/internal-underscore.js +60 -0
  48. package/eslint.config.js +5 -0
  49. package/package.json +3 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A TypeScript Entity Component System (ECS) for real-time games and simulations.
4
4
 
5
- `vecs` lets you model game state as **entities** (integer IDs) with **components** (typed data bags) attached to them. **Systems** declare which component combinations they care about and receive automatic callbacks when entities enter or leave their query, when component data changes, and on every tick. A **World** ties it all together and drives the update loop.
5
+ `vecs` lets you model game state as **entities** (numeric ids) with **components** (typed data bags) attached to them. **Systems** declare which component combinations they care about and receive automatic callbacks when entities enter or leave their query, when component data changes, and on every tick. A **World** ties it all together and drives the update loop.
6
6
 
7
7
  ## Install
8
8
 
@@ -14,18 +14,20 @@ yarn add @vworlds/vecs
14
14
 
15
15
  | Concept | What it is |
16
16
  | ------------------------ | ------------------------------------------------------------------------------- |
17
- | **World** | Central container. Owns all entities, runs all systems and queries. |
17
+ | **World** | Central container. Owns every entity, query, system, and pipeline phase. |
18
18
  | **Component** | A plain data class. Extend `Component` and attach instances to entities. |
19
- | **Entity** | An integer id with a set of components. Create via the world. |
20
- | **Query** | A reactive, always-updated set of entities that match a predicate. |
21
- | **System** | A `Query` with per-tick runtime logic (phases, `update`, `each`, `run`). |
19
+ | **Entity** | A numeric id with a set of components. Created via the world. |
20
+ | **Query** | A reactive, always-up-to-date set of entities matching a predicate. |
21
+ | **System** | A `Query` with phase placement and per-tick logic (`update`, `each`, `run`). |
22
22
  | **Filter** | A non-reactive, one-shot scan: walks all world entities on each `forEach` call. |
23
- | **Exclusive components** | A group of components where at most one may be present on any entity at a time. |
23
+ | **Hook** | Lightweight `onAdd` / `onRemove` / `onSet` callbacks per component class. |
24
+ | **Phase** | Named ordered bucket of systems within the update pipeline. |
25
+ | **Exclusive components** | A group of components where at most one may exist on any entity at a time. |
24
26
 
25
27
  ### Lifecycle in brief
26
28
 
27
29
  ```
28
- registerComponent() × N → system() / query() × N → start() → progress() every frame
30
+ registerComponent() × N → addPhase() / system() / query() × N → start() → progress() every frame
29
31
  ```
30
32
 
31
33
  After `start()`, component registration is disabled. Systems and queries can still be created — standalone queries backfill existing matched entities immediately.
@@ -34,8 +36,6 @@ After `start()`, component registration is disabled. Systems and queries can sti
34
36
 
35
37
  ## Example
36
38
 
37
- The example below defines three components, two systems, a phase, and a hook, then runs a simple "move and despawn" loop.
38
-
39
39
  ```ts
40
40
  import { World, Component, IPhase } from "@vworlds/vecs";
41
41
 
@@ -70,22 +70,15 @@ const cleanup: IPhase = world.addPhase("cleanup");
70
70
 
71
71
  // ─── Systems ───────────────────────────────────────────────────────────────
72
72
 
73
- // MoveSystem: runs every tick for entities that have both Position and Velocity.
73
+ // MoveSystem: integrates Velocity into Position every tick.
74
74
  world
75
75
  .system("Move")
76
76
  .phase(update)
77
77
  .requires(Position, Velocity)
78
- .enter([Position, Velocity], (e, [pos, vel]) => {
79
- console.log(`entity ${e.eid} entered Move with pos=(${pos.x},${pos.y})`);
80
- })
81
- .update(Velocity, [Position], (vel, [pos]) => {
82
- // Called whenever vel.modified() is queued.
78
+ .each([Position, Velocity], (e, [pos, vel]) => {
83
79
  pos.x += vel.vx;
84
80
  pos.y += vel.vy;
85
- pos.modified(); // propagate position change to other systems
86
- })
87
- .exit((e) => {
88
- console.log(`entity ${e.eid} left Move`);
81
+ pos.modified(); // signal that Position changed so other systems react
89
82
  });
90
83
 
91
84
  // HealthSystem: despawns entities whose HP drops to zero.
@@ -101,8 +94,6 @@ world
101
94
 
102
95
  // ─── Hooks ─────────────────────────────────────────────────────────────────
103
96
 
104
- // Hooks are a lightweight alternative to systems for side effects on a single
105
- // component type — no per-entity query, just callbacks on add/remove/set.
106
97
  world
107
98
  .hook(Health)
108
99
  .onAdd((h) => console.log(`entity ${h.entity.eid} spawned with hp=${h.hp}`))
@@ -110,16 +101,11 @@ world
110
101
 
111
102
  // ─── Start ─────────────────────────────────────────────────────────────────
112
103
 
113
- world.start(); // freeze registration, sort systems into phases
114
-
115
- // ─── Create entities ───────────────────────────────────────────────────────
116
-
117
- const bullet = world.entity();
118
- bullet.set(Position, { x: 0, y: 0 });
104
+ world.start(); // freeze registration, distribute systems into phases
119
105
 
120
- const vel = bullet.set(Velocity, { vx: 5, vy: 0 }).get(Velocity)!;
106
+ // ─── Spawn entities ────────────────────────────────────────────────────────
121
107
 
122
- const hp = bullet.set(Health, { hp: 3 }).get(Health)!;
108
+ world.entity().set(Position, { x: 0, y: 0 }).set(Velocity, { vx: 5, vy: 0 }).set(Health, { hp: 3 });
123
109
 
124
110
  // ─── Game loop ─────────────────────────────────────────────────────────────
125
111
 
@@ -132,11 +118,25 @@ for (let tick = 0; tick < 5; tick++) {
132
118
 
133
119
  ---
134
120
 
121
+ ## Deferred mode
122
+
123
+ Inside a system body, a `Query.forEach`, or any `world.defer(...)` block, the world is in **deferred mode**: entity mutations (`add` / `set` / `remove` / `destroy` / `setParent` / `modified`) are queued instead of applied inline. The queue drains on the boundary that opened the deferred scope.
124
+
125
+ Concretely, while deferred:
126
+
127
+ - `entity.get(C)` returns `undefined` after `entity.add(C)` (no instance has been created yet).
128
+ - `entity.get(C)` returns the **previous** value after `entity.set(C, props)`.
129
+ - `entity.get(C)` still returns the component after `entity.remove(C)`.
130
+
131
+ Outside any deferred scope (top-level user code) the same calls execute inline and effects are visible immediately. `world.flush()` drains any pending top-level commands; `world.defer(fn)` is sugar for `beginDefer / fn / endDefer`.
132
+
133
+ ---
134
+
135
135
  ## API Reference
136
136
 
137
- ### World
137
+ ### `World`
138
138
 
139
- The world owns everything. Create one per game session.
139
+ Create one per game session.
140
140
 
141
141
  ```ts
142
142
  const world = new World();
@@ -145,24 +145,22 @@ const world = new World();
145
145
  #### Component registration
146
146
 
147
147
  ```ts
148
- // Auto-assigned type id (starts at 256 for "local" components):
148
+ // Auto-assigned type id ( 256 for "local" components):
149
149
  world.registerComponent(Position);
150
150
 
151
- // Explicit numeric type id (required when the id comes from a server):
151
+ // Explicit numeric type id (e.g. server-assigned):
152
152
  world.registerComponent(Position, 1);
153
153
 
154
- // With a display name different from the class name:
154
+ // Explicit display name (e.g. when the class name differs from the network name):
155
155
  world.registerComponent(Position, "pos");
156
156
 
157
157
  // Pre-register a name → id mapping before the class is available:
158
158
  world.registerComponentType("Position", 1);
159
159
  ```
160
160
 
161
- After `world.start()` any further call to `registerComponent` throws.
162
-
163
- #### Exclusive components
161
+ After `world.start()` (or `world.disableComponentRegistration()`) any further call to `registerComponent` throws.
164
162
 
165
- Declare a group of components that cannot coexist on the same entity. Adding a member of the group automatically removes any other member that was already present.
163
+ #### Exclusive component groups
166
164
 
167
165
  ```ts
168
166
  world.setExclusiveComponents(Walking, Running, Idle);
@@ -170,12 +168,9 @@ world.setExclusiveComponents(Walking, Running, Idle);
170
168
  const e = world.entity();
171
169
  e.add(Walking);
172
170
  e.add(Running); // Walking is automatically removed first
173
- // e.get(Walking) === undefined, e.get(Running) is defined
174
171
  ```
175
172
 
176
- Each call to `setExclusiveComponents` defines one independent group. Components not in the group are unaffected. A component may belong to at most one exclusivity group (calling `setExclusiveComponents` a second time with the same class overwrites its group).
177
-
178
- `setExclusiveComponents` may be called before or after `world.start()`.
173
+ Each call defines one independent group. A component may belong to at most one group; calling `setExclusiveComponents` again with the same class overwrites its group. Safe to call before or after `world.start()`.
179
174
 
180
175
  #### Entity management
181
176
 
@@ -184,58 +179,147 @@ Each call to `setExclusiveComponents` defines one independent group. Components
184
179
  const e = world.entity();
185
180
 
186
181
  // Look up by id (returns undefined if not found):
187
- const e = world.entity(42);
182
+ const found = world.entity(42);
188
183
 
189
184
  // Server-assigned id; creates the entity if it doesn't exist yet:
190
- const e = world.getOrCreateEntity(serverId, (newEntity) => {
185
+ const net = world.getOrCreateEntity(serverId, (newEntity) => {
191
186
  tracked.add(newEntity);
192
187
  });
193
188
 
189
+ // Reserve a high id range for locally created entities so they don't collide
190
+ // with server-assigned ids (call before world.start()):
191
+ world.setEntityIdRange(0x10000);
192
+
194
193
  // Destroy everything (e.g. on level reset):
195
194
  world.clearAllEntities();
195
+ ```
196
196
 
197
- // Reserve a high id range for locally-created entities so they don't
198
- // collide with server-assigned ids (call before world.start()):
199
- world.setEntityIdRange(0x10000);
197
+ #### Hooks
198
+
199
+ ```ts
200
+ world
201
+ .hook(Sprite)
202
+ .onAdd((sprite) => sprite.initialize(scene))
203
+ .onRemove((sprite) => sprite.destroy())
204
+ .onSet((sprite) => sprite.syncToScene());
205
+ ```
206
+
207
+ `onAdd` fires when the component is first attached. `onRemove` fires when it is removed (or the entity is destroyed). `onSet` fires whenever `component.modified()` (or `entity.modified(c)`) is called, and when `entity.set(C, props)` is applied to an entity that already has the component.
208
+
209
+ #### Phases
210
+
211
+ ```ts
212
+ const preUpdate = world.addPhase("preupdate");
213
+ const update = world.addPhase("update");
214
+ const send = world.addPhase("send");
215
+
216
+ // Drive every phase in registration order:
217
+ world.progress(now, delta);
218
+
219
+ // ...or run individual phases manually:
220
+ world.beginFrame(now, delta);
221
+ try {
222
+ world.runPhase(preUpdate, now, delta);
223
+ world.runPhase(update, now, delta);
224
+ world.runPhase(send, now, delta);
225
+ } finally {
226
+ world.endFrame();
227
+ }
200
228
  ```
201
229
 
230
+ Systems with no explicit phase are placed in the built-in `"update"` phase.
231
+
202
232
  #### Systems
203
233
 
204
234
  ```ts
205
- // Create, configure, and register a system in one chain:
206
- world.system("MySystem")
235
+ world
236
+ .system("MySystem")
207
237
  .phase("update")
208
238
  .requires(A, B)
209
239
  .enter(...)
210
240
  .update(...)
241
+ .each(...)
211
242
  .exit(...);
243
+ ```
244
+
245
+ #### Timers and rate filters
246
+
247
+ Systems can opt into a slower cadence instead of running on every phase tick. `interval()` takes seconds; throttled `run()` callbacks receive the accumulated milliseconds since the previous fire as `delta`.
248
+
249
+ ```ts
250
+ import { IntervalTickSource, RateTickSource } from "@vworlds/vecs";
251
+
252
+ world
253
+ .system("Move")
254
+ .interval(1.0)
255
+ .each([Position], (e, [pos]) => {
256
+ // 1 Hz
257
+ });
258
+
259
+ world
260
+ .system("Move")
261
+ .rate(2)
262
+ .each([Position], (e, [pos]) => {
263
+ // every 2nd frame
264
+ });
212
265
 
213
- world.start(); // distributes systems to phases, freezes component registration
266
+ const second = new IntervalTickSource(1.0);
267
+
268
+ world
269
+ .system("Move")
270
+ .tickSource(second)
271
+ .each([Position], (e, [pos]) => {
272
+ // driven by a shared timer
273
+ });
274
+
275
+ second.stop();
276
+ second.start();
277
+
278
+ const minute = new RateTickSource(60, second);
279
+ const hour = world
280
+ .system("Hour")
281
+ .tickSource(minute)
282
+ .rate(60)
283
+ .run((now, delta) => {
284
+ console.log("hour tick", now, delta);
285
+ });
286
+
287
+ // Systems can also be tick sources for each other.
288
+ const eachSecond = world
289
+ .system("EachSecond")
290
+ .interval(1)
291
+ .run(() => {
292
+ // ...
293
+ });
294
+
295
+ const eachMinute = world
296
+ .system("EachMinute")
297
+ .tickSource(eachSecond)
298
+ .rate(60)
299
+ .run(() => {
300
+ // ...
301
+ });
214
302
  ```
215
303
 
216
- #### Queries
304
+ Tick source objects and systems can both be used as sources. Disabling a source system suppresses its callbacks, but its clock still drives downstream consumers.
217
305
 
218
- A standalone `Query` is a reactive entity set without a phase or per-tick callbacks. Use it when you need the matched set kept up-to-date automatically — for example to enumerate scene nodes or find the nearest enemy.
306
+ #### Queries
219
307
 
220
308
  ```ts
221
309
  const enemies = world
222
310
  .query("Enemies")
223
311
  .requires(Enemy, Health)
224
- .enter((e) => console.log("enemy spawned", e.eid))
225
- .exit((e) => console.log("enemy died", e.eid));
312
+ .enter((e) => console.log("enemy spawned", e.eid));
226
313
 
227
314
  world.start();
228
- // enemies.entities is kept up-to-date automatically
315
+ // enemies.entities is kept up-to-date automatically.
229
316
 
230
- // Can also be created after start(); existing matched entities are backfilled:
231
- const lateQuery = world.query("Walls").requires(Wall);
232
- // lateQuery.entities immediately contains all current Wall entities
317
+ // Standalone queries can also be created after start(); existing matched
318
+ // entities are backfilled immediately.
233
319
  ```
234
320
 
235
321
  #### Filters
236
322
 
237
- A `Filter` is a non-reactive, one-shot scan. It holds no tracked entity set — each `forEach` call walks all world entities at that moment. Use it for ad-hoc lookups that don't need to stay live.
238
-
239
323
  ```ts
240
324
  // Entity only:
241
325
  world.filter([Position]).forEach((e) => console.log(e.eid));
@@ -249,55 +333,30 @@ world.filter([Position, Velocity]).forEach([Position, Velocity], (e, [pos, vel])
249
333
  world
250
334
  .filter({ AND: [{ HAS: Position }, { HAS: Velocity }] })
251
335
  .forEach([Position, Velocity], (e, [pos, vel]) => {
252
- pos.x += vel.vx; // pos and vel are non-null — deduced from AND of HAS
336
+ pos.x += vel.vx;
253
337
  });
254
338
 
255
- // Manual type hint for queries the extractor can't see through:
339
+ // Manual hint for queries the type extractor can't see through:
256
340
  world.filter({ OR: [Position, Velocity] }, [Position]).forEach([Position], (e, [pos]) => pos.x);
257
341
  ```
258
342
 
259
- Unlike `Query`, a `Filter` requires no name, no `world.start()`, and no `destroy()` — create it anywhere and discard it freely.
343
+ A `Filter` requires no name, no `world.start()`, and no `destroy()` — create it anywhere and discard freely.
260
344
 
261
- #### Phases
345
+ #### Pipeline control
262
346
 
263
347
  ```ts
264
- // Declare phases in the order they should run each frame:
265
- const preUpdate = world.addPhase("preupdate");
266
- const update = world.addPhase("update");
267
- const send = world.addPhase("send");
268
-
269
- // Each frame, run all phases in registration order:
270
- world.progress(Date.now(), deltaMs);
271
-
272
- // Or drive individual phases manually:
273
- world.runPhase(preUpdate, Date.now(), deltaMs);
274
- world.runPhase(update, Date.now(), deltaMs);
275
- world.runPhase(send, Date.now(), deltaMs);
276
- ```
277
-
278
- Systems with no explicit phase go into a built-in `"update"` phase.
279
-
280
- #### Hooks
281
-
282
- A hook is a shorthand for reacting to a single component's lifecycle without writing a full system:
348
+ world.start(); // freeze registration, distribute systems
349
+ world.disableComponentRegistration(); // freeze registration without sorting
283
350
 
284
- ```ts
285
- world
286
- .hook(Sprite)
287
- .onAdd((sprite) => sprite.initialize(scene))
288
- .onRemove((sprite) => sprite.destroy())
289
- .onSet((sprite) => sprite.syncToScene());
351
+ world.flush(); // drain queued top-level mutations
352
+ world.defer(() => { ... }); // run a block in deferred mode
353
+ world.beginDefer(); // pair with endDefer() for finer scoping
354
+ world.endDefer();
290
355
  ```
291
356
 
292
- `onSet` fires whenever `component.modified()` is called.
293
- `onAdd` fires when the component is first attached to an entity.
294
- `onRemove` fires when it is removed or the entity is destroyed.
295
-
296
357
  ---
297
358
 
298
- ### Component
299
-
300
- Extend `Component` to define your data:
359
+ ### `Component`
301
360
 
302
361
  ```ts
303
362
  class Position extends Component {
@@ -312,90 +371,80 @@ const pos = entity.get(Position)!;
312
371
  pos.x = 100;
313
372
  pos.modified(); // tell the world this component changed
314
373
 
315
- // Alternatively:
374
+ // Equivalent — set assigns props and fires onSet automatically:
316
375
  entity.set(Position, { x: 100 });
317
376
  ```
318
377
 
319
- Every component instance exposes:
320
-
321
378
  | Property / Method | Description |
322
379
  | ----------------- | --------------------------------------------------------------------- |
323
380
  | `entity` | The `Entity` this component belongs to. |
324
- | `meta` | `ComponentMeta` — holds the type id, name, and bitset pointer. |
381
+ | `meta` | `ComponentMeta` — type id, display name, and bit-pointer. |
325
382
  | `type` | Numeric type id (shorthand for `meta.type`). |
383
+ | `bitPtr` | `BitPtr` (shorthand for `meta.bitPtr`). |
326
384
  | `modified()` | Queue an `onSet` / `update` notification. Call after mutating fields. |
385
+ | `toString()` | Returns the registered component name. |
327
386
 
328
387
  ---
329
388
 
330
- ### Entity
331
-
332
- ```ts
333
- const e = world.entity();
334
- ```
335
-
336
- | Property / Method | Description |
337
- | ---------------------- | ------------------------------------------------------------------------------------------------------------- |
338
- | `eid` | Unique numeric entity id. |
339
- | `world` | The `World` that owns this entity. |
340
- | `add(Class)` | Attach a component and return the entity for chaining. Idempotent. |
341
- | `set(Class, props)` | Like `add`, but also assigns the given partial properties onto the instance. Returns the entity for chaining. |
342
- | `modified(component)` | Queue an `onSet` / `update` notification for the component. Returns the entity for chaining. |
343
- | `get(Class)` | Return the component instance, or `undefined` if not present. |
344
- | `remove(Class)` | Detach a component (triggers `onRemove` hooks and `exit` callbacks). |
345
- | `destroy()` | Remove all components and unregister the entity. Recurses to children. |
346
- | `empty` | `true` when no components are attached. |
347
- | `forEachComponent(cb)` | Iterate over all attached components. |
348
- | `parent` | Parent entity in the scene hierarchy, or `undefined`. |
349
- | `children` | `Set<Entity>` of direct children (lazy, created on first access). |
350
- | `events` | Typed event emitter. Currently emits `"destroy"` before teardown. |
351
- | `properties` | `Map<string, any>` free-form bag for module-level bookkeeping. |
352
-
353
- `entity.modified(c)` is equivalent to `c.modified()` but returns the entity, making it usable in a method chain:
389
+ ### `Entity`
390
+
391
+ Created via `world.entity()` (auto-assigned id) or `world.getOrCreateEntity(id, ...)` (caller-supplied id).
392
+
393
+ | Property / Method | Description |
394
+ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
395
+ | `eid` | Unique numeric entity id. |
396
+ | `world` | The `World` that owns this entity. |
397
+ | `componentBitmask` | `Bitset` of component type ids attached to this entity. Used by archetype matching. |
398
+ | `properties` | `Map<string, any>` free-form bag for module-level bookkeeping. |
399
+ | `add(Class)` | Attach a component (idempotent). Returns the entity for chaining. |
400
+ | `set(Class, props)` | Attach a component and assign `props`; fires `onSet`. Returns the entity for chaining. |
401
+ | `modified(component)` | Queue an `onSet` / `update` notification. Returns the entity for chaining. |
402
+ | `get(Class)` | Return the component instance, or `undefined`. |
403
+ | `remove(Class)` | Detach a component (fires `onRemove` and `exit`). |
404
+ | `destroy()` | Remove all components, unregister from the world, recurse into children. |
405
+ | `components` | `ReadonlyArrayMap<Component>` read-only view of attached components keyed by type id. Supports `forEach`, `get`, `has`, and `size`. |
406
+ | `empty` | `true` when no components are attached. |
407
+ | `parent` | Parent entity, or `undefined` for a root entity. |
408
+ | `children` | `ReadonlySet<Entity>` of direct children (lazy). |
409
+ | `setParent(p)` | Reparent the entity. `undefined` makes it a root entity. Throws on cycles. |
410
+ | `events` | Typed event emitter. Currently emits `"destroy"` just before teardown. |
411
+ | `toString()` | Returns `"EntityN"`. |
412
+
413
+ `entity.modified(c)` is equivalent to `c.modified()` but returns the entity so it can chain:
354
414
 
355
415
  ```ts
356
- // Mutate fields then signal the change inline:
357
416
  const vel = entity.get(Velocity)!;
358
417
  vel.vx += accel;
359
- entity.modified(vel); // same effect as vel.modified(), returns entity
360
-
361
- // Or in a chain — add without initial notification, then notify later:
362
- entity.add(Position, false).modified(entity.get(Position)!);
418
+ entity.modified(vel); // chainable
363
419
  ```
364
420
 
365
- #### Parentchild hierarchy
421
+ #### Parent / child hierarchy
366
422
 
367
423
  ```ts
368
- child.parent = parent;
369
- parent.children.add(child);
424
+ child.setParent(parent);
425
+ parent.children.has(child); // true
370
426
 
371
427
  // Destroying a parent recursively destroys all children:
372
428
  parent.destroy();
373
429
  ```
374
430
 
375
- Archetype queries that use `{ PARENT: ... }` are automatically re-evaluated when a parent's component set changes.
431
+ `setParent` throws if the new parent is a descendant of the entity. Archetype queries that use `{ PARENT: ... }` are re-evaluated automatically when a parent's component set changes.
376
432
 
377
433
  ---
378
434
 
379
- ### System
435
+ ### `System`
380
436
 
381
- Systems are created via `world.system(name)` and configured through a fluent builder API. All methods return `this` for chaining.
437
+ Systems are created via `world.system(name)` and configured through a fluent builder. Every method returns `this` for chaining. `System` extends `Query`, so the membership / enter / exit / update / sort APIs are shared.
382
438
 
383
439
  #### `.requires(...components)` and `.query(q)`
384
440
 
385
- Declare which entities the system should track:
441
+ Declare which entities the system tracks.
386
442
 
387
443
  ```ts
388
- // Entities that have both Position and Velocity:
389
- .requires(Position, Velocity)
390
-
391
- // Equivalent explicit query:
392
- .query({ HAS: [Position, Velocity] })
393
-
394
- // Entities that have a parent with Player AND Container:
395
- .query({ PARENT: { AND: [Player, Container] } })
396
-
397
- // Compound queries:
398
- .query({ AND: [Position, { OR: [Sprite, Container] }] })
444
+ .requires(Position, Velocity) // shorthand for HAS
445
+ .query({ HAS: [Position, Velocity] }) // explicit
446
+ .query({ PARENT: { AND: [Player, Container] } }) // parent-aware
447
+ .query({ AND: [Position, { OR: [Sprite, Container] }] }) // compound
399
448
  .query({ NOT: Invisible })
400
449
  ```
401
450
 
@@ -409,130 +458,128 @@ Declare which entities the system should track:
409
458
  | `{ OR: [q1, q2] }` | Either sub-query matches |
410
459
  | `{ NOT: q }` | Sub-query must not match |
411
460
  | `{ PARENT: q }` | Entity's parent matches q |
412
- | An array `[A, B]` | Shorthand for `HAS: [A, B]` |
461
+ | An array `[A, B]` | Shorthand for `{ HAS: [A, B] }` |
462
+ | A single class / id | Shorthand for `{ HAS: [C] }` |
463
+ | A predicate function | Custom membership logic |
413
464
 
414
- **Type inference:** `requires()` records the listed classes as a type parameter on the system. Callbacks in `.sort()`, `.each()`, and `.update()` inject then treat those components as non-nullable — no `!` needed. For complex `query()` expressions the type system cannot introspect, pass a second argument as an explicit hint:
465
+ **Type inference.** `requires()` records the listed classes as a type parameter `R` on the system. Callbacks in `.sort()`, `.each()`, and `.update()` injection treat those components as non-nullable — no `!` needed. For complex `query()` expressions the type system can't introspect, supply a `_guaranteed` second argument:
415
466
 
416
467
  ```ts
417
468
  .query({ AND: [{ HAS: Position }, { HAS: Velocity }] }, [Position, Velocity])
418
469
  .each([Position, Velocity], (e, [pos, vel]) => {
419
470
  pos.x += vel.vx; // pos and vel are non-null
420
- })
471
+ });
421
472
  ```
422
473
 
423
474
  #### `.phase(p)`
424
475
 
425
- Assign the system to a named phase or an `IPhase` reference. Systems without a phase run in `"update"`.
476
+ Assign the system to a phase by name or `IPhase` reference. Default phase is `"update"`.
426
477
 
427
478
  ```ts
428
- .phase("preupdate") // by name
429
- .phase(myPhase) // by IPhase reference
479
+ .phase("preupdate")
480
+ .phase(myPhase)
430
481
  ```
431
482
 
432
483
  #### `.enter(callback)` / `.enter(inject, callback)`
433
484
 
434
- Called once when an entity first matches the system's query.
485
+ Fires once when an entity first matches the system.
435
486
 
436
487
  ```ts
437
- // No injection:
438
- .enter((e) => { console.log("entity joined", e.eid); })
439
-
440
- // With injection — component instances resolved from the entity:
488
+ .enter((e) => { ... })
441
489
  .enter([Position, Sprite], (e, [pos, sprite]) => {
442
490
  sprite.setPosition(pos.x, pos.y);
443
491
  })
444
492
 
445
- // Resolve from parent:
493
+ // Resolve from the entity's parent:
446
494
  .enter([{ parent: Container }], (e, [container]) => {
447
495
  container.add(e.get(Sprite)!.gameObject);
448
- })
496
+ });
449
497
  ```
450
498
 
451
499
  #### `.exit(callback)` / `.exit(inject, callback)`
452
500
 
453
- Called when an entity leaves the system (component removed or entity destroyed). Components removed in the same frame are still accessible in exit callbacks.
501
+ Fires when an entity leaves the system (component removed or entity destroyed). Components removed in the same frame are still resolvable in `inject`.
454
502
 
455
503
  ```ts
456
- .exit([Sprite], (e, [sprite]) => {
457
- sprite.destroy();
458
- })
504
+ .exit([Sprite], (e, [sprite]) => sprite.destroy());
459
505
  ```
460
506
 
461
507
  #### `.update(ComponentClass, callback)` / `.update(ComponentClass, inject, callback)`
462
508
 
463
- Called when `component.modified()` is queued on a watched component of a tracked entity.
509
+ Fires when `component.modified()` is called for the watched component on a tracked entity.
464
510
 
465
511
  ```ts
466
- // Simple receives the modified component:
467
- .update(Position, (pos) => {
468
- renderer.setPosition(pos.x, pos.y);
469
- })
512
+ .update(Position, (pos) => renderer.setPosition(pos.x, pos.y));
470
513
 
471
- // With injection — receives the modified component and extra components:
472
514
  .update(Position, [Sprite], (pos, [sprite]) => {
473
515
  sprite.sprite.setPosition(pos.x, pos.y);
474
- })
516
+ });
475
517
  ```
476
518
 
477
- Injected components listed in `requires()` are non-nullable in the callback; any others are `Type | undefined`.
478
-
479
- Calling `update` also adds that component type to the system's implicit `HAS` query (unless you called `query()` first).
519
+ If `query()` has not been called, `update` automatically expands the implicit `HAS` predicate to require the watched component.
480
520
 
481
521
  #### `.each(components, callback)`
482
522
 
483
- Called every tick for **every tracked entity**, unconditionally. Unlike `update` (which only fires when `component.modified()` is called), `each` fires regardless of whether the component was modified — use it for per-entity logic that must run on every frame.
484
-
485
- The callback receives the entity and a tuple of resolved component instances. Components declared via `requires()` are guaranteed non-null; any others are `undefined` if the entity lacks them.
523
+ Fires every tick for **every tracked entity**, regardless of whether anything changed. Use it for per-entity logic that must run every frame. Implies `.track()`. Only one `each` per system.
486
524
 
487
525
  ```ts
488
526
  .requires(Position, Velocity)
489
527
  .each([Position, Velocity], (e, [pos, vel]) => {
490
- pos.x += vel.vx; // non-null — both are in requires()
491
- pos.y += vel.vy;
492
- })
528
+ pos.x += vel.vx;
529
+ });
493
530
  ```
494
531
 
495
- `each` does not modify the system's query — define membership with `requires(...)` or `query(...)` as usual. Only one `each` may be registered per system; a second call throws.
496
-
497
532
  #### `.sort(components, compare)`
498
533
 
499
- Enable sorted entity tracking. Matched entities are stored in an ordered set whose insertion position is determined by `compare`, which receives a tuple of resolved component instances for each pair being ordered. Implies `.track()`.
500
-
501
- Components declared via `requires()` are non-null in the compare callback.
534
+ Store matched entities in a custom order determined by `compare`. Implies `.track()`. Iterating `system.entities`, `forEach`, and `each` walks entities in sorted order.
502
535
 
503
536
  ```ts
504
537
  world
505
538
  .system("Render")
506
539
  .requires(Position, Sprite)
507
540
  .sort([Position], ([posA], [posB]) => posA.z - posB.z)
508
- .each([Position, Sprite], (e, [pos, sprite]) => {
509
- sprite.draw(pos.x, pos.y);
510
- });
541
+ .each([Position, Sprite], (e, [pos, sprite]) => sprite.draw(pos.x, pos.y));
511
542
  ```
512
543
 
513
- Iterating `system.entities` after a phase run yields entities in the sorted order.
514
-
515
544
  #### `.track()`
516
545
 
517
- Enable entity tracking without an `each` callback — matched entities are exposed via `system.entities` (or `query.entities`) as they enter and leave. `each` and `sort` imply `track` automatically; call this directly only when you need the tracked set without a per-tick callback.
518
-
519
- When called after `world.start()`, `track()` immediately backfills existing entities that satisfy the query predicate.
546
+ Enable entity tracking without an `each` callback — exposes matched entities via `system.entities`. `each` and `sort` imply `track` automatically. When called after `world.start()`, immediately backfills existing matched entities.
520
547
 
521
548
  #### `.run(callback)`
522
549
 
523
- Called every tick when the system's phase runs, regardless of entity state. Use this for polling, network I/O, timers, etc.
550
+ Fires every tick when the system's phase runs, regardless of entity state. Use for polling, network I/O, timers, etc.
524
551
 
525
552
  ```ts
526
553
  .run((now, delta) => {
527
554
  sendNetworkPacket(now);
528
- })
555
+ });
529
556
  ```
530
557
 
558
+ #### `.disable()` / `.enable()`
559
+
560
+ Pause and resume a system at runtime. While disabled the system is effectively invisible: the inbox is cleared immediately, any new `enter`, `exit`, or `update` events are silently dropped, `run` and `each` callbacks do not fire, and the system skips its `_run` entirely. Entity membership in the underlying query is still maintained, so the tracked set remains correct and the system resumes cleanly when re-enabled. Events that occurred while the system was disabled are **not** replayed.
561
+
562
+ ```ts
563
+ const ai = world.system("AI").requires(Enemy).run(tickAI);
564
+
565
+ // Pause AI processing during a cutscene:
566
+ ai.disable();
567
+
568
+ // Resume normal processing:
569
+ ai.enable();
570
+ ```
571
+
572
+ Both methods return `this` for chaining and are idempotent (calling `disable()` on an already-disabled system, or `enable()` on an already-enabled system, is a no-op).
573
+
574
+ #### `.destroy()`
575
+
576
+ **Not supported on `System`** — calling it throws. Systems live for the duration of the world. Use a standalone `Query` for temporary reactive sets.
577
+
531
578
  ---
532
579
 
533
- ### Query
580
+ ### `Query`
534
581
 
535
- A standalone query is created via `world.query(name)` and configured through the same fluent builder API as `System` (`requires`, `query`, `enter`, `exit`, `sort`, `track`, `forEach`, `entities`). It has no phase and no per-tick callbacks.
582
+ `world.query(name)` returns a standalone reactive entity set, configured through the same builder API as `System`. It has no phase and no per-tick callbacks.
536
583
 
537
584
  ```ts
538
585
  const projectiles = world
@@ -545,46 +592,42 @@ const projectiles = world
545
592
 
546
593
  world.start();
547
594
 
548
- // Anywhere in game code:
549
- projectiles.forEach((e) => {
550
- /* ... */
551
- });
595
+ projectiles.forEach((e) => { ... });
552
596
  console.log(projectiles.entities.size, "active projectiles");
553
597
  ```
554
598
 
555
- | Method | Description |
556
- | ----------------------------------------------- | ------------------------------------------------------------------------- |
557
- | `.requires(...components)` | Set the membership predicate and start tracking. |
558
- | `.query(expr)` | Set the membership predicate using the {@link SystemQuery} DSL. |
559
- | `.enter(callback)` / `.enter(inject, callback)` | Fires when an entity joins the query. |
560
- | `.exit(callback)` / `.exit(inject, callback)` | Fires when an entity leaves the query. |
561
- | `.sort(components, compare)` | Store matched entities in sorted order. |
562
- | `.track()` | Enable tracking (implied by `sort`; backfills when called after `start`). |
563
- | `.belongs(e)` | Returns `true` if the entity satisfies the predicate. |
564
- | `.forEach(callback)` | Iterate all currently tracked entities (entity only). |
565
- | `.forEach(components, callback)` | Iterate with component injection — same signature as `Filter.forEach`. |
566
- | `.entities` | `ReadonlySet<Entity>` of all currently tracked entities. |
567
- | `.destroy()` | Remove the query from the world and all entities. See below. |
599
+ | Method | Description |
600
+ | ------------------------------------------------------- | ------------------------------------------------------------------------ |
601
+ | `.requires(...components)` | Set the membership predicate to `HAS(...components)` and start tracking. |
602
+ | `.query(expr, _guaranteed?)` | Set the membership predicate using a `QueryDSL` expression. |
603
+ | `.enter(callback)` / `.enter(inject, callback)` | Fires when an entity joins the query. |
604
+ | `.exit(callback)` / `.exit(inject, callback)` | Fires when an entity leaves the query. |
605
+ | `.update(C, callback)` / `.update(C, inject, callback)` | Fires when `C` is modified on a tracked entity. |
606
+ | `.sort(components, compare)` | Store matched entities in sorted order. |
607
+ | `.track()` | Enable tracking. Backfills when called after `start()`. |
608
+ | `.belongs(e)` | Returns `true` if the entity satisfies the predicate. |
609
+ | `.forEach(callback)` | Iterate currently tracked entities. |
610
+ | `.forEach(components, callback)` | Iterate with component injection. |
611
+ | `.entities` | `ReadonlySet<Entity>` of currently tracked entities. |
612
+ | `.destroy()` | Remove the query from the world and from every entity (no exit fires). |
568
613
 
569
- #### `.destroy()`
614
+ #### `.destroy()` semantics
570
615
 
571
- Permanently removes a standalone query from the world. All entity references are silently purged (no exit callbacks fire), the tracked entity set is cleared, and the query's `world` reference is set to `undefined`. After this call, any use of the query object is **undefined behavior**.
616
+ `destroy()` permanently removes a standalone query from the world. Entity references are silently purged (no `exit` callbacks fire), the tracked set is cleared, and the `world` reference is set to `undefined`. Any further use of the object is **undefined behavior**.
572
617
 
573
618
  ```ts
574
619
  const q = world.query("Temporary").requires(Position);
575
620
  // ... use q.entities ...
576
- q.destroy(); // unregisters from world and all entities
621
+ q.destroy();
577
622
  ```
578
623
 
579
- `System` does **not** support `destroy()` calling it throws. Systems are owned by the world for the lifetime of the session. Use a standalone `Query` when you need a temporary reactive set.
580
-
581
- Both `System` and `Query` share the same query DSL, enter/exit callbacks, sort, and `entities` set — `System` extends `Query` and layers phase execution on top.
624
+ `System` shares the same DSL, callback, sorting, and tracking machinery `System` extends `Query` and adds phase placement, `run`, `each`, and an inbox replayed on every tick.
582
625
 
583
626
  ---
584
627
 
585
- ### Filter
628
+ ### `Filter`
586
629
 
587
- A `Filter` is created via `world.filter(dsl)` and provides a non-reactive `forEach`. It accepts the same [`QueryDSL`](#-requirescomponents-and-queryq) expressions as systems and queries.
630
+ `world.filter(dsl)` returns a `Filter` that performs a non-reactive scan. It accepts the same `QueryDSL` expressions as systems and queries.
588
631
 
589
632
  ```ts
590
633
  const f = world.filter([Position, Velocity]);
@@ -592,30 +635,58 @@ const f = world.filter([Position, Velocity]);
592
635
 
593
636
  | Method | Description |
594
637
  | -------------------------------- | -------------------------------------------------------------------------- |
595
- | `.forEach(callback)` | Walk all world entities; invoke callback for each matching one. |
638
+ | `.forEach(callback)` | Walk all world entities; invoke callback on each match. |
596
639
  | `.forEach(components, callback)` | Same, with component injection and non-null types for required components. |
597
640
 
598
- **Type inference** works the same way as for `requires()` on systems/queries: component classes extractable from the DSL (`HAS`, `HAS_ONLY`, plain arrays, and `AND` of those) are non-nullable in the callback tuple. Pass a `_guaranteed` second argument to `world.filter()` as a manual override when inference can't reach:
641
+ `forEach` runs inside a deferred scope, so mutations made by the callback are batched and become visible after iteration finishes.
642
+
643
+ **Type inference.** Component classes the type system can extract from the DSL (`HAS`, `HAS_ONLY`, plain arrays, `AND` of those) are non-nullable in the callback tuple. For the rest, supply a `_guaranteed` second argument to `world.filter()`:
599
644
 
600
645
  ```ts
601
646
  // Auto-deduced — both non-null:
602
- world.filter([Position, Velocity])
603
- .forEach([Position, Velocity], (e, [pos, vel]) => { ... });
647
+ world.filter([Position, Velocity]).forEach([Position, Velocity], (e, [pos, vel]) => { ... });
604
648
 
605
649
  // Manual hint for OR / NOT / PARENT / custom function:
606
- world.filter({ OR: [Position, Velocity] }, [Position])
607
- .forEach([Position], (e, [pos]) => pos.x);
650
+ world.filter({ OR: [Position, Velocity] }, [Position]).forEach([Position], (e, [pos]) => pos.x);
608
651
  ```
609
652
 
610
653
  A `Filter` holds no tracked set, makes no registration calls, and needs no `destroy()`.
611
654
 
612
655
  ---
613
656
 
657
+ ### `Bitset`
658
+
659
+ A compact, growable set of non-negative integers backed by 32-bit words. Used internally for entity archetypes and watchlists, and exposed in the public API so component data can use it for bit-flag fields.
660
+
661
+ | Method | Description |
662
+ | ------------------ | ------------------------------------------------------------------------ |
663
+ | `add(n)` | Set bit `n`. |
664
+ | `addBit(bptr)` | Set the bit at a pre-computed `BitPtr`. |
665
+ | `delete(n)` | Clear bit `n`. Trims trailing zero words. |
666
+ | `has(n)` | Returns `true` if bit `n` is set. |
667
+ | `hasBit(bptr)` | Fast check via a pre-computed `BitPtr`. |
668
+ | `equal(other)` | Returns `true` when both bitsets have the same bits set. |
669
+ | `hasBitset(other)` | Returns `true` when every bit set in `other` is also set in this bitset. |
670
+ | `forEach(cb)` | Visit each set bit index in ascending order. |
671
+ | `indices()` | Return all set bit indices as a `number[]`. |
672
+
673
+ ```ts
674
+ class Tags extends Component {
675
+ tags = new Bitset();
676
+ }
677
+
678
+ tags.tags.add(TAG_VISIBLE);
679
+ if (tags.tags.has(TAG_VISIBLE)) { ... }
680
+ ```
681
+
682
+ ---
683
+
614
684
  ## Build & Test
615
685
 
616
686
  ```
617
687
  yarn build
618
688
  yarn test
689
+ yarn lint
619
690
  ```
620
691
 
621
692
  ---