iris-ecs 0.0.1

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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +721 -0
  3. package/dist/actions.d.ts +43 -0
  4. package/dist/actions.d.ts.map +1 -0
  5. package/dist/actions.js +35 -0
  6. package/dist/actions.js.map +1 -0
  7. package/dist/archetype.d.ts +194 -0
  8. package/dist/archetype.d.ts.map +1 -0
  9. package/dist/archetype.js +412 -0
  10. package/dist/archetype.js.map +1 -0
  11. package/dist/component.d.ts +89 -0
  12. package/dist/component.d.ts.map +1 -0
  13. package/dist/component.js +237 -0
  14. package/dist/component.js.map +1 -0
  15. package/dist/encoding.d.ts +204 -0
  16. package/dist/encoding.d.ts.map +1 -0
  17. package/dist/encoding.js +215 -0
  18. package/dist/encoding.js.map +1 -0
  19. package/dist/entity.d.ts +129 -0
  20. package/dist/entity.d.ts.map +1 -0
  21. package/dist/entity.js +243 -0
  22. package/dist/entity.js.map +1 -0
  23. package/dist/event.d.ts +237 -0
  24. package/dist/event.d.ts.map +1 -0
  25. package/dist/event.js +293 -0
  26. package/dist/event.js.map +1 -0
  27. package/dist/filters.d.ts +121 -0
  28. package/dist/filters.d.ts.map +1 -0
  29. package/dist/filters.js +202 -0
  30. package/dist/filters.js.map +1 -0
  31. package/dist/index.d.ts +24 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +54 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/name.d.ts +70 -0
  36. package/dist/name.d.ts.map +1 -0
  37. package/dist/name.js +172 -0
  38. package/dist/name.js.map +1 -0
  39. package/dist/observer.d.ts +83 -0
  40. package/dist/observer.d.ts.map +1 -0
  41. package/dist/observer.js +62 -0
  42. package/dist/observer.js.map +1 -0
  43. package/dist/query.d.ts +198 -0
  44. package/dist/query.d.ts.map +1 -0
  45. package/dist/query.js +299 -0
  46. package/dist/query.js.map +1 -0
  47. package/dist/registry.d.ts +118 -0
  48. package/dist/registry.d.ts.map +1 -0
  49. package/dist/registry.js +112 -0
  50. package/dist/registry.js.map +1 -0
  51. package/dist/relation.d.ts +60 -0
  52. package/dist/relation.d.ts.map +1 -0
  53. package/dist/relation.js +171 -0
  54. package/dist/relation.js.map +1 -0
  55. package/dist/removal.d.ts +27 -0
  56. package/dist/removal.d.ts.map +1 -0
  57. package/dist/removal.js +66 -0
  58. package/dist/removal.js.map +1 -0
  59. package/dist/resource.d.ts +78 -0
  60. package/dist/resource.d.ts.map +1 -0
  61. package/dist/resource.js +86 -0
  62. package/dist/resource.js.map +1 -0
  63. package/dist/scheduler.d.ts +106 -0
  64. package/dist/scheduler.d.ts.map +1 -0
  65. package/dist/scheduler.js +204 -0
  66. package/dist/scheduler.js.map +1 -0
  67. package/dist/schema.d.ts +117 -0
  68. package/dist/schema.d.ts.map +1 -0
  69. package/dist/schema.js +113 -0
  70. package/dist/schema.js.map +1 -0
  71. package/dist/world.d.ts +172 -0
  72. package/dist/world.d.ts.map +1 -0
  73. package/dist/world.js +127 -0
  74. package/dist/world.js.map +1 -0
  75. package/package.json +52 -0
package/README.md ADDED
@@ -0,0 +1,721 @@
1
+ # iris-ecs
2
+
3
+ High-performance, TypeScript-idiomatic Entity Component System.
4
+
5
+ ## What is ECS?
6
+
7
+ **Entity Component System** is a design pattern that separates *identity*, *data*, and *behavior*:
8
+
9
+ - **Entities** are unique identifiers -- just IDs
10
+ - **Components** are plain data attached to entities
11
+ - **Systems** are functions that query and process entities by their components
12
+
13
+ A player can be an entity with `Position`, `Health`, and `PlayerInput` components. A tree might be an entity with `Position` and `Sprite`. A movement system queries all entities with `Position` and `Velocity` -- it doesn't care if they're players, enemies, or projectiles.
14
+
15
+ This shifts how you model problems: instead of asking "what *type* is this object?", you ask "what *components* does this entity have?" Components can be added and removed at runtime, so entities gain and lose capabilities dynamically.
16
+
17
+ ### When to use ECS
18
+
19
+ ECS works well when you have **many entities sharing overlapping behaviors**. Games are the classic example: bullets, enemies, particles, and players all need position updates, but only some need AI, only some need player input, only some render sprites. A system that moves things doesn't need to know about rendering; a system that renders doesn't need to know about AI.
20
+
21
+ ECS also fits **simulations** (agent-based models, traffic flow, ecosystems), **editors** (level editors, graphics tools with many selectable/transformable objects), and **interactive visualizations** with many updatable elements.
22
+
23
+ ECS is not a good fit for everything. Simple CRUD applications, form-heavy UIs, or problems where you have few entities with complex, unique behaviors may be better served by straightforward objects or state management libraries.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ npm install iris-ecs
29
+ ```
30
+
31
+ ## Quick Start
32
+
33
+ ```typescript
34
+ import {
35
+ createWorld,
36
+ createEntity,
37
+ defineComponent,
38
+ defineTag,
39
+ addComponent,
40
+ getComponentValue,
41
+ setComponentValue,
42
+ fetchEntities,
43
+ addSystem,
44
+ buildSchedule,
45
+ executeSchedule,
46
+ Type,
47
+ } from "iris-ecs";
48
+
49
+ // Define components
50
+ const Position = defineComponent("Position", { x: Type.f32(), y: Type.f32() });
51
+ const Velocity = defineComponent("Velocity", { x: Type.f32(), y: Type.f32() });
52
+ const Player = defineTag("Player");
53
+
54
+ // Create world and entities
55
+ const world = createWorld();
56
+
57
+ const player = createEntity(world);
58
+ addComponent(world, player, Position, { x: 0, y: 0 });
59
+ addComponent(world, player, Velocity, { x: 1, y: 0 });
60
+ addComponent(world, player, Player);
61
+
62
+ // Define a system
63
+ function movementSystem(world) {
64
+ for (const e of fetchEntities(world, Position, Velocity)) {
65
+ const px = getComponentValue(world, e, Position, "x");
66
+ const py = getComponentValue(world, e, Position, "y");
67
+ const vx = getComponentValue(world, e, Velocity, "x");
68
+ const vy = getComponentValue(world, e, Velocity, "y");
69
+
70
+ setComponentValue(world, e, Position, "x", px + vx);
71
+ setComponentValue(world, e, Position, "y", py + vy);
72
+ }
73
+ }
74
+
75
+ // Register and run systems
76
+ addSystem(world, movementSystem);
77
+ buildSchedule(world);
78
+ executeSchedule(world);
79
+
80
+ // Position is now { x: 1, y: 0 }
81
+ ```
82
+
83
+ ## Core Concepts
84
+
85
+ ### Entities
86
+
87
+ An **Entity** is a unique identifier representing a thing in your world. Entities have no data of their own -- they're containers for components.
88
+
89
+ ```typescript
90
+ import {
91
+ createWorld,
92
+ createEntity,
93
+ destroyEntity,
94
+ isEntityAlive,
95
+ resetWorld,
96
+ } from "iris-ecs";
97
+
98
+ const world = createWorld();
99
+
100
+ const player = createEntity(world);
101
+ const enemy = createEntity(world);
102
+
103
+ destroyEntity(world, enemy);
104
+ isEntityAlive(world, enemy); // false
105
+ isEntityAlive(world, player); // true
106
+
107
+ // Clear all entities and state, keeping component/tag definitions
108
+ resetWorld(world);
109
+ ```
110
+
111
+ Create entities with `createEntity()`, destroy them with `destroyEntity()`. Use `isEntityAlive()` to check if an entity reference is still valid. Call `resetWorld()` to clear all entities and state while preserving definitions -- useful for level reloads or testing.
112
+
113
+ ⚠️ **Entity IDs are recycled.** After destroying an entity, its ID may be reused for a new entity. Never store entity IDs long-term without checking `isEntityAlive()` first -- your old reference might now point to a different entity.
114
+
115
+ #### Everything is an Entity
116
+
117
+ Components, tags, and relations are also entities internally. When you call `defineComponent()` or `defineTag()`, you're creating a special entity that can be attached to other entities. This unified model means components can have components, enabling patterns like adding metadata to component types.
118
+
119
+ All IDs are 32-bit encoded values with type bits distinguishing entities (0x1), tags (0x2), components (0x3), and relations (0x4). Entity IDs include an 8-bit generation counter for stale reference detection -- when an ID is recycled, its generation increments, invalidating old references.
120
+
121
+ #### Entity Names
122
+
123
+ Entities can be given human-readable names for debugging and lookup. Names must be unique within a world.
124
+
125
+ ```typescript
126
+ import { setName, getName, removeName, lookupByName } from "iris-ecs";
127
+
128
+ setName(world, player, "player-1");
129
+ getName(world, player); // "player-1"
130
+ lookupByName(world, "player-1"); // player entity
131
+
132
+ // Validate components during lookup -- returns entity only if it has both
133
+ lookupByName(world, "player-1", Position, Health);
134
+
135
+ removeName(world, player);
136
+ lookupByName(world, "player-1"); // undefined
137
+ ```
138
+
139
+ Names are automatically cleaned up when entities are destroyed. Use names for integrations, save/load systems, or any scenario where you need to reference entities by string identifier.
140
+
141
+ 💡 **Tip:** Names are great for debugging -- use `setName()` on important entities to make logs more readable.
142
+
143
+ ### Tags
144
+
145
+ A **Tag** is a marker component with no data.
146
+
147
+ ```typescript
148
+ import { defineTag, addComponent, hasComponent, removeComponent } from "iris-ecs";
149
+
150
+ const Player = defineTag("Player");
151
+ const Enemy = defineTag("Enemy");
152
+ const Poisoned = defineTag("Poisoned");
153
+
154
+ addComponent(world, entity, Player);
155
+ hasComponent(world, entity, Player); // true
156
+
157
+ removeComponent(world, entity, Player);
158
+ hasComponent(world, entity, Player); // false
159
+ ```
160
+
161
+ Tags are lightweight -- they only affect which archetype an entity belongs to. Use tags when you need to filter entities but don't need associated data.
162
+
163
+ ### Components
164
+
165
+ A **Component** holds typed data attached to an entity. Define components with a schema specifying field names and types.
166
+
167
+ ```typescript
168
+ import {
169
+ defineComponent,
170
+ Type,
171
+ addComponent,
172
+ getComponentValue,
173
+ setComponentValue,
174
+ } from "iris-ecs";
175
+
176
+ const Position = defineComponent("Position", { x: Type.f32(), y: Type.f32() });
177
+ const Health = defineComponent("Health", { current: Type.i32(), max: Type.i32() });
178
+
179
+ addComponent(world, entity, Position, { x: 0, y: 0 });
180
+ addComponent(world, entity, Health, { current: 100, max: 100 });
181
+
182
+ const x = getComponentValue(world, entity, Position, "x"); // 0
183
+ setComponentValue(world, entity, Position, "x", 10);
184
+ ```
185
+
186
+ #### Schema Types
187
+
188
+ The `Type` namespace provides storage-optimized types:
189
+
190
+ | Type | Storage | Use case |
191
+ |------|---------|----------|
192
+ | `Type.f32()` | Float32Array | Positions, velocities, normalized values |
193
+ | `Type.f64()` | Float64Array | High-precision calculations |
194
+ | `Type.i8()` | Int8Array | Small signed integers (-128 to 127) |
195
+ | `Type.i16()` | Int16Array | Medium signed integers |
196
+ | `Type.i32()` | Int32Array | Entity counts, scores, health |
197
+ | `Type.u32()` | Uint32Array | Unsigned integers, bit flags |
198
+ | `Type.bool()` | Array | Boolean flags |
199
+ | `Type.string()` | Array | Text data |
200
+ | `Type.object<T>()` | Array | Complex nested objects |
201
+
202
+ Numeric types use TypedArrays for cache-friendly memory layout. Use the smallest type that fits your data.
203
+
204
+ #### Adding Components is Idempotent
205
+
206
+ Adding a component that already exists does nothing -- the existing data is preserved.
207
+
208
+ ```typescript
209
+ addComponent(world, entity, Position, { x: 0, y: 0 });
210
+ addComponent(world, entity, Position, { x: 99, y: 99 }); // ignored
211
+
212
+ getComponentValue(world, entity, Position, "x"); // still 0
213
+ ```
214
+
215
+ 💡 **Tip:** Use `hasComponent()` to check first if you need conditional addition, or `setComponentValue()` to update existing data.
216
+
217
+ ### Resources
218
+
219
+ A **Resource** is a global singleton -- world-level data that isn't attached to any specific entity. Define resources using regular components and store them with `addResource()`.
220
+
221
+ ```typescript
222
+ import {
223
+ defineComponent,
224
+ addResource,
225
+ getResourceValue,
226
+ setResourceValue,
227
+ hasResource,
228
+ removeResource,
229
+ Type,
230
+ } from "iris-ecs";
231
+
232
+ const Time = defineComponent("Time", { delta: Type.f32(), elapsed: Type.f32() });
233
+
234
+ addResource(world, Time, { delta: 0.016, elapsed: 0 });
235
+
236
+ // Read and write resource values
237
+ const dt = getResourceValue(world, Time, "delta"); // 0.016
238
+ setResourceValue(world, Time, "elapsed", 1.5);
239
+
240
+ // Check existence and remove
241
+ if (hasResource(world, Time)) {
242
+ removeResource(world, Time);
243
+ }
244
+ ```
245
+
246
+ Resources use the **component-on-self pattern** internally -- the component is added to itself as an entity. This means resources appear in queries:
247
+
248
+ ```typescript
249
+ for (const entity of fetchEntities(world, Time)) {
250
+ // entity === Time (the component ID itself)
251
+ }
252
+ ```
253
+
254
+ Use resources for frame timing, configuration, asset registry, input state, physics settings, or any global data that systems need but doesn't belong to a specific entity.
255
+
256
+ ### Relations
257
+
258
+ A **Relation** describes a directed connection between two entities. Combine a relation with a target using `pair()` to create a pair -- pairs are added to entities like components.
259
+
260
+ ```typescript
261
+ import {
262
+ defineRelation,
263
+ pair,
264
+ addComponent,
265
+ fetchEntities,
266
+ getRelationTargets,
267
+ Wildcard,
268
+ } from "iris-ecs";
269
+
270
+ const ChildOf = defineRelation("ChildOf");
271
+
272
+ const scene = createEntity(world);
273
+ const player = createEntity(world);
274
+ const weapon = createEntity(world);
275
+
276
+ addComponent(world, player, pair(ChildOf, scene));
277
+ addComponent(world, weapon, pair(ChildOf, player));
278
+
279
+ // Query children of a specific parent
280
+ for (const child of fetchEntities(world, pair(ChildOf, scene))) {
281
+ // child === player
282
+ }
283
+
284
+ // Get all targets for a relation on an entity
285
+ const parents = getRelationTargets(world, weapon, ChildOf); // [player]
286
+ ```
287
+
288
+ Use relations for hierarchies (parent/child), ownership, targeting, dependencies, or any directed graph structure.
289
+
290
+ #### Wildcard Queries
291
+
292
+ Use `Wildcard` to match any relation or target:
293
+
294
+ ```typescript
295
+ // All entities with ANY ChildOf relation (any target)
296
+ const allChildren = [...fetchEntities(world, pair(ChildOf, Wildcard))];
297
+
298
+ // All entities targeting a specific entity (any relation)
299
+ const relatedToPlayer = [...fetchEntities(world, pair(Wildcard, player))];
300
+ ```
301
+
302
+ #### Exclusive Relations
303
+
304
+ An **exclusive** relation allows only one target per entity. Adding a new pair automatically removes the previous one.
305
+
306
+ ```typescript
307
+ const ChildOf = defineRelation("ChildOf", { exclusive: true });
308
+
309
+ addComponent(world, entity, pair(ChildOf, parent1));
310
+ addComponent(world, entity, pair(ChildOf, parent2)); // removes parent1
311
+
312
+ getRelationTargets(world, entity, ChildOf); // [parent2]
313
+ ```
314
+
315
+ #### Cascade Deletion
316
+
317
+ By default, destroying a target entity removes pairs pointing to it but leaves subjects alive. Use `onDeleteTarget: "delete"` to cascade-delete subjects when the target is destroyed.
318
+
319
+ ```typescript
320
+ const ChildOf = defineRelation("ChildOf", { onDeleteTarget: "delete" });
321
+
322
+ const parent = createEntity(world);
323
+ const child = createEntity(world);
324
+ addComponent(world, child, pair(ChildOf, parent));
325
+
326
+ destroyEntity(world, parent);
327
+ isEntityAlive(world, child); // false -- cascaded
328
+ ```
329
+
330
+ #### Data Relations
331
+
332
+ Relations can carry data, just like components:
333
+
334
+ ```typescript
335
+ const Targets = defineRelation("Targets", {
336
+ schema: { priority: Type.i8() },
337
+ });
338
+
339
+ addComponent(world, turret, pair(Targets, enemy), { priority: 10 });
340
+
341
+ const p = pair(Targets, enemy);
342
+ const priority = getComponentValue(world, turret, p, "priority");
343
+ ```
344
+
345
+ ### Archetypes (Under the Hood)
346
+
347
+ An **Archetype** groups entities that share the same component set. All entities with `Position` and `Velocity` live in one archetype; entities with `Position`, `Velocity`, and `Health` live in another.
348
+
349
+ ```
350
+ Archetype [Position, Velocity]
351
+ ┌─────────┬─────────┬─────────┐
352
+ │ Entity │ Pos.x/y │ Vel.x/y │
353
+ ├─────────┼─────────┼─────────┤
354
+ │ bullet1 │ 10, 5 │ 1, 0 │
355
+ │ bullet2 │ 15, 8 │ 1, 0 │
356
+ └─────────┴─────────┴─────────┘
357
+
358
+ Archetype [Position, Velocity, Health]
359
+ ┌─────────┬─────────┬─────────┬─────────┐
360
+ │ Entity │ Pos.x/y │ Vel.x/y │ Health │
361
+ ├─────────┼─────────┼─────────┼─────────┤
362
+ │ player │ 0, 0 │ 1, 0 │ 100 │
363
+ │ enemy │ 50, 20 │ -1, 0 │ 50 │
364
+ └─────────┴─────────┴─────────┴─────────┘
365
+ ```
366
+
367
+ Within an archetype, component data is stored in **columns** (TypedArrays for numeric types). When a query iterates entities with `Position` and `Velocity`, it walks through archetypes that contain both components. This columnar layout keeps data contiguous rather than scattered across objects, reducing memory overhead and enabling efficient iteration.
368
+
369
+ Adding or removing a component moves an entity to a different archetype. This is more expensive than reading or writing component values, so prefer stable component sets for entities that update frequently.
370
+
371
+ 💡 **Tip:** You don't interact with archetypes directly -- the ECS handles them automatically. Understanding the model helps you design components that group well and avoid unnecessary archetype transitions.
372
+
373
+ ### Queries
374
+
375
+ A **Query** fetches entities that match a set of component constraints. Use `fetchEntities()` to iterate all matches or `fetchFirstEntity()` for singletons.
376
+
377
+ ```typescript
378
+ import { fetchEntities, fetchFirstEntity, not } from "iris-ecs";
379
+
380
+ // Iterate all entities with Position and Velocity
381
+ for (const entity of fetchEntities(world, Position, Velocity)) {
382
+ const x = getComponentValue(world, entity, Position, "x");
383
+ // ...
384
+ }
385
+
386
+ // Get a singleton (first match or undefined)
387
+ const player = fetchFirstEntity(world, Player, not(Dead));
388
+ ```
389
+
390
+ 💡 **Tip:** Queries are cached internally -- the same component set returns the same cached query.
391
+
392
+ #### Exclusion Filters
393
+
394
+ Use `not()` to exclude entities that have a component:
395
+
396
+ ```typescript
397
+ // All entities with Position but WITHOUT the Dead tag
398
+ for (const entity of fetchEntities(world, Position, not(Dead))) {
399
+ // Only living entities
400
+ }
401
+
402
+ // Multiple exclusions
403
+ for (const entity of fetchEntities(world, Position, Velocity, not(Frozen), not(Disabled))) {
404
+ // Entities that can move
405
+ }
406
+ ```
407
+
408
+ #### Filters and Archetypes (Under the Hood)
409
+
410
+ Queries match archetypes where all required components are present and no excluded components exist. Matched archetypes are cached and auto-update when new archetypes are created.
411
+
412
+ ### Systems
413
+
414
+ A **System** is a function that operates on the world. Systems query entities, read and write components, emit events, and implement game logic.
415
+
416
+ ```typescript
417
+ import {
418
+ addSystem,
419
+ buildSchedule,
420
+ executeSchedule,
421
+ fetchEntities,
422
+ getComponentValue,
423
+ setComponentValue,
424
+ } from "iris-ecs";
425
+
426
+ function movementSystem(world) {
427
+ for (const e of fetchEntities(world, Position, Velocity)) {
428
+ const px = getComponentValue(world, e, Position, "x");
429
+ const py = getComponentValue(world, e, Position, "y");
430
+ const vx = getComponentValue(world, e, Velocity, "x");
431
+ const vy = getComponentValue(world, e, Velocity, "y");
432
+
433
+ setComponentValue(world, e, Position, "x", px + vx);
434
+ setComponentValue(world, e, Position, "y", py + vy);
435
+ }
436
+ }
437
+
438
+ addSystem(world, movementSystem);
439
+ buildSchedule(world);
440
+
441
+ // Game loop
442
+ while (running) {
443
+ executeSchedule(world);
444
+ }
445
+ ```
446
+
447
+ Systems are registered with `addSystem()`, ordered by `buildSchedule()`, and run with `executeSchedule()`. The system function's name becomes its identifier.
448
+
449
+ #### Ordering Constraints
450
+
451
+ Control execution order with `before` and `after` options:
452
+
453
+ ```typescript
454
+ function inputSystem(world) { /* read input */ }
455
+ function physicsSystem(world) { /* simulate physics */ }
456
+ function renderSystem(world) { /* draw frame */ }
457
+
458
+ addSystem(world, inputSystem);
459
+ addSystem(world, physicsSystem, { after: "inputSystem" });
460
+ addSystem(world, renderSystem, { after: "physicsSystem" });
461
+
462
+ buildSchedule(world);
463
+ // Executes: inputSystem -> physicsSystem -> renderSystem
464
+ ```
465
+
466
+ Without constraints, systems run in registration order. Use arrays for multiple constraints: `{ after: ["inputSystem", "audioSystem"] }`.
467
+
468
+ 💡 **Tip:** The system function's name becomes its identifier. Use named functions, not arrow functions, for systems you need to reference in ordering constraints.
469
+
470
+ #### Schedules
471
+
472
+ Systems are grouped into **schedules**. The default schedule is `"runtime"`, but you can create others for initialization, cleanup, or custom phases:
473
+
474
+ ```typescript
475
+ addSystem(world, loadAssetsSystem, { schedule: "startup" });
476
+ addSystem(world, saveGameSystem, { schedule: "shutdown" });
477
+ addSystem(world, physicsSystem); // defaults to "runtime"
478
+
479
+ buildSchedule(world, "startup");
480
+ buildSchedule(world, "runtime");
481
+ buildSchedule(world, "shutdown");
482
+
483
+ executeSchedule(world, "startup"); // Run once at start
484
+ while (running) {
485
+ executeSchedule(world); // "runtime" is default
486
+ }
487
+ executeSchedule(world, "shutdown"); // Run once at end
488
+ ```
489
+
490
+ #### Async Systems
491
+
492
+ For systems that need to `await` (loading assets, network calls), use `executeScheduleAsync()`:
493
+
494
+ ```typescript
495
+ async function loadAssetsSystem(world) {
496
+ const textures = await fetch("/assets/textures.json");
497
+ // ...
498
+ }
499
+
500
+ addSystem(world, loadAssetsSystem, { schedule: "startup" });
501
+ buildSchedule(world, "startup");
502
+
503
+ await executeScheduleAsync(world, "startup");
504
+ ```
505
+
506
+ ⚠️ `executeSchedule()` throws if any system returns a Promise. Use `executeScheduleAsync()` for schedules with async systems.
507
+
508
+ ### Actions
509
+
510
+ **Actions** bundle reusable operations with a world captured in closure. Define actions once, then call them without repeatedly passing the world.
511
+
512
+ ```typescript
513
+ import { defineActions, createEntity, addComponent } from "iris-ecs";
514
+
515
+ const spawnActions = defineActions((world) => ({
516
+ player(x: number, y: number) {
517
+ const entity = createEntity(world);
518
+ addComponent(world, entity, Position, { x, y });
519
+ addComponent(world, entity, Player);
520
+ return entity;
521
+ },
522
+ enemy(x: number, y: number) {
523
+ const entity = createEntity(world);
524
+ addComponent(world, entity, Position, { x, y });
525
+ addComponent(world, entity, Enemy);
526
+ return entity;
527
+ },
528
+ }));
529
+
530
+ // In a system or anywhere with world access
531
+ const spawn = spawnActions(world);
532
+ const player = spawn.player(0, 0);
533
+ const enemy = spawn.enemy(100, 50);
534
+ ```
535
+
536
+ Actions are initialized lazily and cached per world -- calling `spawnActions(world)` multiple times returns the same object.
537
+
538
+ 💡 **Tip:** Use actions to organize spawn helpers, update functions, or any reusable world operations.
539
+
540
+ ### Events
541
+
542
+ An **Event** is an ephemeral message for communication between systems. Unlike components (persistent data on entities), events are fire-and-forget: emit once, consume once per system, then gone.
543
+
544
+ ```typescript
545
+ import { defineEvent, emitEvent, fetchEvents, Type } from "iris-ecs";
546
+
547
+ // Tag event (no data)
548
+ const GameStarted = defineEvent("GameStarted");
549
+
550
+ // Data event
551
+ const DamageDealt = defineEvent("DamageDealt", {
552
+ target: Type.u32(),
553
+ amount: Type.f32(),
554
+ });
555
+
556
+ // Emit events
557
+ emitEvent(world, GameStarted);
558
+ emitEvent(world, DamageDealt, { target: enemy, amount: 25 });
559
+
560
+ // Consume events in a system
561
+ function damageSystem(world) {
562
+ for (const event of fetchEvents(world, DamageDealt)) {
563
+ applyDamage(event.target, event.amount);
564
+ }
565
+ }
566
+ ```
567
+
568
+ Use events when systems need to react to something that happened without polling entity state. Common patterns: collision notifications, input events, game state transitions.
569
+
570
+ #### Per-System Isolation
571
+
572
+ Each system independently tracks which events it has consumed. Multiple systems can read the same events:
573
+
574
+ ```typescript
575
+ function uiSystem(world) {
576
+ for (const e of fetchEvents(world, DamageDealt)) {
577
+ showDamageNumber(e.target, e.amount);
578
+ }
579
+ }
580
+
581
+ function audioSystem(world) {
582
+ for (const e of fetchEvents(world, DamageDealt)) {
583
+ playHitSound(e.amount);
584
+ }
585
+ }
586
+
587
+ // Both systems see the same DamageDealt events
588
+ ```
589
+
590
+ #### Event Utilities
591
+
592
+ ```typescript
593
+ import {
594
+ hasEvents,
595
+ countEvents,
596
+ fetchLastEvent,
597
+ clearEvents,
598
+ } from "iris-ecs";
599
+
600
+ // Check without consuming
601
+ if (hasEvents(world, DamageDealt)) {
602
+ const count = countEvents(world, DamageDealt);
603
+ }
604
+
605
+ // Get only the most recent event (marks all as read)
606
+ const lastInput = fetchLastEvent(world, InputChanged);
607
+
608
+ // Skip events without processing
609
+ if (isPaused) {
610
+ clearEvents(world, DamageDealt);
611
+ return;
612
+ }
613
+ ```
614
+
615
+ #### Event Lifetime
616
+
617
+ Events persist for a short window (2 ticks) to ensure all systems can read them regardless of execution order, then expire automatically. Calling `fetchEvents()` marks events as read for that system -- a second call in the same system sees nothing new.
618
+
619
+ ⚠️ **Events are not entities.** Unlike components and tags, events exist outside the entity-component model. You cannot query for events or attach them to entities.
620
+
621
+ ### Change Detection
622
+
623
+ **Change detection** tracks when components are added, modified, or removed, letting systems process only what changed since their last run.
624
+
625
+ ```typescript
626
+ import {
627
+ fetchEntities,
628
+ added,
629
+ changed,
630
+ removed,
631
+ fetchEvents,
632
+ } from "iris-ecs";
633
+
634
+ // Entities where Position was added this tick
635
+ for (const entity of fetchEntities(world, added(Position))) {
636
+ initializePhysicsBody(entity);
637
+ }
638
+
639
+ // Entities where Health was modified (added OR value changed)
640
+ for (const entity of fetchEntities(world, changed(Health))) {
641
+ updateHealthBar(entity);
642
+ }
643
+
644
+ // Combine with regular filters
645
+ for (const e of fetchEntities(world, Player, changed(Position), not(Dead))) {
646
+ updatePlayerOnMinimap(e);
647
+ }
648
+ ```
649
+
650
+ Each system tracks changes independently -- if two systems query `added(Position)`, both see the same newly added entities.
651
+
652
+ #### Detecting Removal
653
+
654
+ Use `removed()` to detect when a component is removed from an entity. Unlike `added()` and `changed()`, removal detection uses the event system:
655
+
656
+ ```typescript
657
+ // Iterate removal events (not a query filter)
658
+ for (const event of fetchEvents(world, removed(Health))) {
659
+ playDeathAnimation(event.entity);
660
+ }
661
+ ```
662
+
663
+ #### Under the Hood
664
+
665
+ Removal detection works differently because when an entity loses a component, it moves to a new archetype -- the old archetype's data becomes inaccessible. Rather than maintain slow global storage for deleted components, `removed()` emits events before the transition occurs. This keeps the fast archetype-local design while enabling removal detection.
666
+
667
+ ### Observers
668
+
669
+ An **Observer** is a callback that fires in response to ECS lifecycle events. Unlike the event system (for inter-system communication), observers hook directly into internal ECS operations.
670
+
671
+ ```typescript
672
+ import {
673
+ registerObserverCallback,
674
+ unregisterObserverCallback,
675
+ } from "iris-ecs";
676
+
677
+ // React to entity creation
678
+ registerObserverCallback(world, "entityCreated", (entity) => {
679
+ console.log(`Entity ${entity} created`);
680
+ });
681
+
682
+ // React to component changes
683
+ registerObserverCallback(world, "componentAdded", (compId, entityId) => {
684
+ console.log(`Component ${compId} added to entity ${entityId}`);
685
+ });
686
+
687
+ // Unregister when done
688
+ const handler = (entity) => { /* ... */ };
689
+ registerObserverCallback(world, "entityDestroyed", handler);
690
+ unregisterObserverCallback(world, "entityDestroyed", handler);
691
+ ```
692
+
693
+ #### Available Events
694
+
695
+ | Event | Payload | When |
696
+ |-------|---------|------|
697
+ | `entityCreated` | `(entity)` | After `createEntity()` |
698
+ | `entityDestroyed` | `(entityId)` | Before entity cleanup |
699
+ | `componentAdded` | `(componentId, entityId)` | After component added |
700
+ | `componentRemoved` | `(componentId, entityId)` | Before component removed |
701
+ | `componentChanged` | `(componentId, entityId)` | After `setComponentValue()` |
702
+ | `archetypeCreated` | `(archetype)` | After archetype created |
703
+ | `archetypeDestroyed` | `(archetype)` | Before archetype cleanup |
704
+ | `worldReset` | `(world)` | After `resetWorld()` |
705
+
706
+ Use observers for debugging, logging, editor integration, or triggering side effects that must happen immediately when the ECS state changes.
707
+
708
+ 💡 **Tip:** For game logic that reacts to changes, prefer change detection queries or the event system. Observers are best for low-level integrations.
709
+
710
+ ## Acknowledgments
711
+
712
+ iris-ecs builds on ideas from these excellent ECS libraries:
713
+
714
+ - [Flecs](https://github.com/SanderMertens/flecs) - Sander Mertens' [Medium articles](https://ajmmertens.medium.com/) on archetype storage and the "everything is an entity" model shaped core architecture. Entity naming, ID encoding, and resource patterns follow Flecs footsteps.
715
+ - [Bevy](https://github.com/bevyengine/bevy) - The change detection API (`added`, `changed`), system scheduling with ordering constraints, and event system design draw heavily from Bevy's approach.
716
+ - [Koota](https://github.com/pmndrs/koota) - My introduction to ECS. Demonstrated how far TypeScript ECS ergonomics can go. The actions API pattern comes directly from Koota.
717
+ - [Jecs](https://github.com/Ukendio/jecs) - The [thesis paper](https://github.com/Ukendio/jecs/blob/b7a5785dbbeefa4cb035673f4eec4f93440acc48/thesis/drafts/1/paper.pdf) on archetype internals, ID encoding strategies, and relation semantics informed the implementation.
718
+
719
+ ## License
720
+
721
+ MIT