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.
- package/LICENSE +21 -0
- package/README.md +721 -0
- package/dist/actions.d.ts +43 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +35 -0
- package/dist/actions.js.map +1 -0
- package/dist/archetype.d.ts +194 -0
- package/dist/archetype.d.ts.map +1 -0
- package/dist/archetype.js +412 -0
- package/dist/archetype.js.map +1 -0
- package/dist/component.d.ts +89 -0
- package/dist/component.d.ts.map +1 -0
- package/dist/component.js +237 -0
- package/dist/component.js.map +1 -0
- package/dist/encoding.d.ts +204 -0
- package/dist/encoding.d.ts.map +1 -0
- package/dist/encoding.js +215 -0
- package/dist/encoding.js.map +1 -0
- package/dist/entity.d.ts +129 -0
- package/dist/entity.d.ts.map +1 -0
- package/dist/entity.js +243 -0
- package/dist/entity.js.map +1 -0
- package/dist/event.d.ts +237 -0
- package/dist/event.d.ts.map +1 -0
- package/dist/event.js +293 -0
- package/dist/event.js.map +1 -0
- package/dist/filters.d.ts +121 -0
- package/dist/filters.d.ts.map +1 -0
- package/dist/filters.js +202 -0
- package/dist/filters.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +54 -0
- package/dist/index.js.map +1 -0
- package/dist/name.d.ts +70 -0
- package/dist/name.d.ts.map +1 -0
- package/dist/name.js +172 -0
- package/dist/name.js.map +1 -0
- package/dist/observer.d.ts +83 -0
- package/dist/observer.d.ts.map +1 -0
- package/dist/observer.js +62 -0
- package/dist/observer.js.map +1 -0
- package/dist/query.d.ts +198 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +299 -0
- package/dist/query.js.map +1 -0
- package/dist/registry.d.ts +118 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +112 -0
- package/dist/registry.js.map +1 -0
- package/dist/relation.d.ts +60 -0
- package/dist/relation.d.ts.map +1 -0
- package/dist/relation.js +171 -0
- package/dist/relation.js.map +1 -0
- package/dist/removal.d.ts +27 -0
- package/dist/removal.d.ts.map +1 -0
- package/dist/removal.js +66 -0
- package/dist/removal.js.map +1 -0
- package/dist/resource.d.ts +78 -0
- package/dist/resource.d.ts.map +1 -0
- package/dist/resource.js +86 -0
- package/dist/resource.js.map +1 -0
- package/dist/scheduler.d.ts +106 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +204 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/schema.d.ts +117 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +113 -0
- package/dist/schema.js.map +1 -0
- package/dist/world.d.ts +172 -0
- package/dist/world.d.ts.map +1 -0
- package/dist/world.js +127 -0
- package/dist/world.js.map +1 -0
- 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
|