ecspresso 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,6 +15,8 @@ A type-safe, modular, and extensible Entity Component System (ECS) framework for
15
15
  - **Screen Management**: Game state/screen transitions with overlay support
16
16
  - **Entity Hierarchy**: Parent-child relationships with traversal and cascade deletion
17
17
  - **Query System**: Powerful entity filtering with helper type utilities
18
+ - **System Phases**: Named execution phases (preUpdate → fixedUpdate → update → postUpdate → render) with fixed-timestep simulation
19
+ - **Change Detection**: Per-system monotonic sequence change tracking with `changed` query filters
18
20
  - **Reactive Queries**: Enter/exit callbacks when entities match or unmatch queries
19
21
  - **System Groups**: Enable/disable groups of systems at runtime
20
22
  - **Component Lifecycle**: Callbacks for component add/remove with unsubscribe support
@@ -27,6 +29,31 @@ A type-safe, modular, and extensible Entity Component System (ECS) framework for
27
29
  npm install ecspresso
28
30
  ```
29
31
 
32
+ ## Table of Contents
33
+
34
+ - [Quick Start](#quick-start)
35
+ - [Core Concepts](#core-concepts)
36
+ - [Entities and Components](#entities-and-components) -- [Component Callbacks](#component-callbacks), [Reactive Queries](#reactive-queries)
37
+ - [Systems and Queries](#systems-and-queries)
38
+ - [Resources](#resources)
39
+ - [Systems in Depth](#systems-in-depth)
40
+ - [Method Chaining](#method-chaining)
41
+ - [Query Type Utilities](#query-type-utilities)
42
+ - [System Phases](#system-phases)
43
+ - [System Priority](#system-priority)
44
+ - [System Groups](#system-groups)
45
+ - [System Lifecycle](#system-lifecycle)
46
+ - [Events](#events)
47
+ - [Entity Hierarchy](#entity-hierarchy) -- [Traversal](#traversal), [Parent-First Traversal](#parent-first-traversal), [Cascade Deletion](#cascade-deletion)
48
+ - [Change Detection](#change-detection) -- [Marking Changes](#marking-changes), [Changed Query Filter](#changed-query-filter), [Sequence Timing](#sequence-timing)
49
+ - [Command Buffer](#command-buffer) -- [Available Commands](#available-commands)
50
+ - [Bundles](#bundles) -- [Built-in Bundles](#built-in-bundles), [Timer Bundle](#timer-bundle)
51
+ - [Asset Management](#asset-management)
52
+ - [Screen Management](#screen-management) -- [Screen-Scoped Systems](#screen-scoped-systems), [Screen Resource](#screen-resource)
53
+ - [Type Safety](#type-safety)
54
+ - [Error Handling](#error-handling)
55
+ - [Performance Tips](#performance-tips)
56
+
30
57
  ## Quick Start
31
58
 
32
59
  ```typescript
@@ -88,21 +115,72 @@ world.entityManager.removeComponent(entity.id, 'velocity');
88
115
  world.entityManager.removeEntity(entity.id);
89
116
  ```
90
117
 
118
+ #### Component Callbacks
119
+
120
+ React to component additions and removals. Both methods return an unsubscribe function:
121
+
122
+ ```typescript
123
+ const unsubAdd = world.onComponentAdded('health', (value, entity) => {
124
+ console.log(`Health added to entity ${entity.id}:`, value);
125
+ });
126
+
127
+ const unsubRemove = world.onComponentRemoved('health', (oldValue, entity) => {
128
+ console.log(`Health removed from entity ${entity.id}:`, oldValue);
129
+ });
130
+
131
+ // Unsubscribe when done
132
+ unsubAdd();
133
+ unsubRemove();
134
+ ```
135
+
136
+ Also available on `world.entityManager.onComponentAdded()` / `onComponentRemoved()`.
137
+
138
+ #### Reactive Queries
139
+
140
+ Get callbacks when entities enter or exit a query match. Unlike regular queries that you poll during `update()`, reactive queries push notifications when the entity's components change:
141
+
142
+ ```typescript
143
+ world.addReactiveQuery('enemies', {
144
+ with: ['position', 'enemy'],
145
+ without: ['dead'],
146
+ onEnter: (entity) => {
147
+ console.log(`Enemy ${entity.id} appeared at`, entity.components.position);
148
+ spawnHealthBar(entity.id);
149
+ },
150
+ onExit: (entityId) => {
151
+ // Receives ID since entity may already be removed
152
+ console.log(`Enemy ${entityId} gone`);
153
+ removeHealthBar(entityId);
154
+ },
155
+ });
156
+
157
+ // Triggers onEnter: spawning matching entity, adding required component, removing excluded component
158
+ const enemy = world.spawn({ position: { x: 0, y: 0 }, enemy: true }); // onEnter fires
159
+
160
+ // Triggers onExit: removing required component, adding excluded component, removing entity
161
+ world.entityManager.addComponent(enemy.id, 'dead', true); // onExit fires
162
+
163
+ // Existing matching entities trigger onEnter when query is added
164
+ // Component replacement does NOT trigger enter/exit (match status unchanged)
165
+
166
+ // Remove reactive query when no longer needed
167
+ world.removeReactiveQuery('enemies'); // returns true if existed
168
+ ```
169
+
91
170
  ### Systems and Queries
92
171
 
93
172
  Systems process entities that match specific component patterns:
94
173
 
95
174
  ```typescript
96
175
  world.addSystem('combat')
97
- .addQuery('fighters', {
98
- with: ['position', 'health'],
99
- without: ['dead']
176
+ .addQuery('fighters', {
177
+ with: ['position', 'health'],
178
+ without: ['dead']
100
179
  })
101
- .addQuery('projectiles', {
102
- with: ['position', 'damage']
180
+ .addQuery('projectiles', {
181
+ with: ['position', 'damage']
103
182
  })
104
183
  .setProcess((queries, deltaTime) => {
105
- // Process fighters and projectiles
106
184
  for (const fighter of queries.fighters) {
107
185
  for (const projectile of queries.projectiles) {
108
186
  // Combat logic here
@@ -114,7 +192,7 @@ world.addSystem('combat')
114
192
 
115
193
  ### Resources
116
194
 
117
- Resources provide global state accessible to all systems:
195
+ Resources provide global state accessible to all systems.
118
196
 
119
197
  ```typescript
120
198
  interface Resources {
@@ -124,9 +202,21 @@ interface Resources {
124
202
 
125
203
  const world = new ECSpresso<Components, {}, Resources>();
126
204
 
127
- // Add resources
205
+ // Direct values
128
206
  world.addResource('score', { value: 0 });
129
- world.addResource('settings', { difficulty: 'easy' });
207
+
208
+ // Sync or async factories (lazy initialization)
209
+ world.addResource('config', () => ({ difficulty: 'normal', soundEnabled: true }));
210
+ world.addResource('database', async () => await connectToDatabase());
211
+
212
+ // Factory with dependencies (initialized after dependencies are ready)
213
+ world.addResource('cache', {
214
+ dependsOn: ['database'],
215
+ factory: (ecs) => ({ db: ecs.getResource('database') })
216
+ });
217
+
218
+ // Initialize all resources (respects dependency order, detects circular deps)
219
+ await world.initializeResources();
130
220
 
131
221
  // Use in systems
132
222
  world.addSystem('scoring')
@@ -137,14 +227,48 @@ world.addSystem('scoring')
137
227
  .build();
138
228
  ```
139
229
 
140
- ## Working with Systems
230
+ **Builder pattern** -- resources chain naturally with other builder methods:
231
+
232
+ ```typescript
233
+ const world = ECSpresso
234
+ .create<Components, Events, Resources>()
235
+ .withBundle(physicsBundle)
236
+ .withResource('config', { debug: true, maxEntities: 1000 })
237
+ .withResource('score', () => ({ value: 0 }))
238
+ .withResource('cache', {
239
+ dependsOn: ['database'],
240
+ factory: (ecs) => createCache(ecs.getResource('database'))
241
+ })
242
+ .build();
243
+ ```
244
+
245
+ **Disposal** -- resources can define cleanup logic with `onDispose` callbacks:
246
+
247
+ ```typescript
248
+ world.addResource('keyboard', {
249
+ factory: () => {
250
+ const handler = (e: KeyboardEvent) => { /* ... */ };
251
+ window.addEventListener('keydown', handler);
252
+ return { handler };
253
+ },
254
+ onDispose: (resource) => {
255
+ window.removeEventListener('keydown', resource.handler);
256
+ }
257
+ });
258
+
259
+ await world.disposeResource('keyboard'); // Dispose a single resource
260
+ await world.disposeResources(); // All, in reverse dependency order
261
+ ```
262
+
263
+ `onDispose` receives the resource value and the ECSpresso instance. Supports sync and async callbacks. Only initialized resources have their `onDispose` called. `removeResource()` still exists for removal without disposal.
264
+
265
+ ## Systems in Depth
141
266
 
142
267
  ### Method Chaining
143
268
 
144
- Chain multiple systems using `.and()` for cleaner code. The `.and()` method returns the parent container (ECSpresso or Bundle), enabling fluent chaining:
269
+ Chain multiple systems using `.and()`. The `.and()` method returns the parent container (ECSpresso or Bundle), enabling fluent chaining:
145
270
 
146
271
  ```typescript
147
- // Chaining systems on ECSpresso
148
272
  world.addSystem('physics')
149
273
  .addQuery('moving', { with: ['position', 'velocity'] })
150
274
  .setProcess((queries, deltaTime) => {
@@ -157,16 +281,6 @@ world.addSystem('physics')
157
281
  // Rendering logic
158
282
  })
159
283
  .build();
160
-
161
- // Chaining systems in a Bundle
162
- const bundle = new Bundle<Components>()
163
- .addSystem('movement')
164
- .setProcess(() => { /* ... */ })
165
- .and() // Returns Bundle for continued chaining
166
- .addSystem('collision')
167
- .setProcess(() => { /* ... */ })
168
- .and()
169
- .addResource('config', { speed: 10 });
170
284
  ```
171
285
 
172
286
  ### Query Type Utilities
@@ -178,14 +292,13 @@ import { createQueryDefinition, QueryResultEntity } from 'ecspresso';
178
292
 
179
293
  // Create reusable query definitions
180
294
  const movingQuery = createQueryDefinition<Components>({
181
- with: ['position', 'velocity'] as const,
182
- without: ['frozen'] as const
295
+ with: ['position', 'velocity'],
296
+ without: ['frozen']
183
297
  });
184
298
 
185
299
  // Extract entity type for helper functions
186
300
  type MovingEntity = QueryResultEntity<Components, typeof movingQuery>;
187
301
 
188
- // Create type-safe helper functions
189
302
  function updatePosition(entity: MovingEntity, deltaTime: number) {
190
303
  entity.components.position.x += entity.components.velocity.x * deltaTime;
191
304
  entity.components.position.y += entity.components.velocity.y * deltaTime;
@@ -202,18 +315,68 @@ world.addSystem('movement')
202
315
  .build();
203
316
  ```
204
317
 
318
+ ### System Phases
319
+
320
+ Systems are organized into named execution phases that run in a fixed order:
321
+
322
+ ```
323
+ preUpdate → fixedUpdate → update → postUpdate → render
324
+ ```
325
+
326
+ Each phase's command buffer is played back before the next phase begins, so entities spawned in `preUpdate` are visible to `fixedUpdate`, and so on. Systems without `.inPhase()` default to `update`.
327
+
328
+ ```typescript
329
+ world.addSystem('input')
330
+ .inPhase('preUpdate')
331
+ .setProcess((queries, dt, ecs) => { /* Read input, update timers */ })
332
+ .and()
333
+ .addSystem('physics')
334
+ .inPhase('fixedUpdate')
335
+ .setProcess((queries, dt, ecs) => {
336
+ // dt is always fixedDt here (e.g. 1/60)
337
+ // Runs 0..N times per frame based on accumulated time
338
+ })
339
+ .and()
340
+ .addSystem('gameplay')
341
+ .inPhase('update') // default phase
342
+ .setProcess((queries, dt, ecs) => { /* Game logic, AI */ })
343
+ .and()
344
+ .addSystem('transform-sync')
345
+ .inPhase('postUpdate')
346
+ .setProcess((queries, dt, ecs) => { /* Transform propagation */ })
347
+ .and()
348
+ .addSystem('renderer')
349
+ .inPhase('render')
350
+ .setProcess((queries, dt, ecs) => { /* Visual output */ })
351
+ .build();
352
+ ```
353
+
354
+ **Fixed Timestep** -- The `fixedUpdate` phase uses a time accumulator for deterministic simulation. A spiral-of-death cap (8 steps) prevents runaway accumulation.
355
+
356
+ ```typescript
357
+ const world = ECSpresso.create<Components, Events, Resources>()
358
+ .withFixedTimestep(1 / 60) // 60Hz physics (default)
359
+ .build();
360
+ ```
361
+
362
+ **Interpolation** -- Use `ecs.interpolationAlpha` (0..1) in the render phase to smooth between fixed steps.
363
+
364
+ **Runtime Phase Changes** -- Move systems between phases at runtime with `world.updateSystemPhase('debug-overlay', 'render')`.
365
+
205
366
  ### System Priority
206
367
 
207
- Control execution order with priorities (higher numbers execute first):
368
+ Within each phase, systems execute in priority order (higher numbers first). Systems with the same priority execute in registration order:
208
369
 
209
370
  ```typescript
210
371
  world.addSystem('physics')
211
- .setPriority(100) // Runs first
372
+ .inPhase('fixedUpdate')
373
+ .setPriority(100) // Runs first within fixedUpdate
212
374
  .setProcess(() => { /* physics */ })
213
375
  .and()
214
- .addSystem('rendering')
215
- .setPriority(50) // Runs second
216
- .setProcess(() => { /* rendering */ })
376
+ .addSystem('constraints')
377
+ .inPhase('fixedUpdate')
378
+ .setPriority(50) // Runs second within fixedUpdate
379
+ .setProcess(() => { /* constraints */ })
217
380
  .build();
218
381
  ```
219
382
 
@@ -222,7 +385,6 @@ world.addSystem('physics')
222
385
  Organize systems into groups that can be enabled/disabled at runtime:
223
386
 
224
387
  ```typescript
225
- // Assign systems to groups
226
388
  world.addSystem('renderSprites')
227
389
  .inGroup('rendering')
228
390
  .addQuery('sprites', { with: ['position', 'sprite'] })
@@ -234,96 +396,68 @@ world.addSystem('renderSprites')
234
396
  .setProcess(() => { /* ... */ })
235
397
  .build();
236
398
 
237
- // Disable/enable groups at runtime
238
- world.disableSystemGroup('rendering'); // All rendering systems skip
239
- world.enableSystemGroup('rendering'); // Resume rendering
240
-
241
- // Query group state
242
- world.isSystemGroupEnabled('rendering'); // true/false
243
- world.getSystemsInGroup('rendering'); // ['renderSprites', 'renderParticles']
399
+ world.disableSystemGroup('rendering'); // All rendering systems skip
400
+ world.enableSystemGroup('rendering'); // Resume rendering
401
+ world.isSystemGroupEnabled('rendering'); // true/false
402
+ world.getSystemsInGroup('rendering'); // ['renderSprites', 'renderParticles']
244
403
 
245
404
  // If a system belongs to multiple groups, disabling ANY group skips the system
246
- world.disableSystemGroup('effects'); // renderParticles won't run
247
405
  ```
248
406
 
249
- ## Advanced Features
250
-
251
- ### Bundles
407
+ ### System Lifecycle
252
408
 
253
- Organize related systems and resources into reusable bundles:
409
+ Systems can have initialization, cleanup, and post-update hooks:
254
410
 
255
411
  ```typescript
256
- import { Bundle } from 'ecspresso';
257
-
258
- interface GameComponents {
259
- position: { x: number; y: number };
260
- velocity: { x: number; y: number };
261
- sprite: { texture: string };
262
- }
263
-
264
- interface GameResources {
265
- gravity: { value: number };
266
- }
267
-
268
- // Create a bundle with multiple systems using .and() for chaining
269
- const physicsBundle = new Bundle<GameComponents, {}, GameResources>('physics')
270
- .addSystem('applyVelocity')
271
- .addQuery('moving', { with: ['position', 'velocity'] })
272
- .setProcess((queries, deltaTime) => {
273
- for (const entity of queries.moving) {
274
- entity.components.position.x += entity.components.velocity.x * deltaTime;
275
- entity.components.position.y += entity.components.velocity.y * deltaTime;
276
- }
412
+ world.addSystem('gameSystem')
413
+ .setOnInitialize(async (ecs) => {
414
+ console.log('System starting...');
277
415
  })
278
- .and() // Returns the bundle for continued chaining
279
- .addSystem('applyGravity')
280
- .addQuery('falling', { with: ['velocity'] })
281
- .setProcess((queries, deltaTime, ecs) => {
282
- const gravity = ecs.getResource('gravity');
283
- for (const entity of queries.falling) {
284
- entity.components.velocity.y += gravity.value * deltaTime;
285
- }
416
+ .setOnDetach((ecs) => {
417
+ console.log('System shutting down...');
286
418
  })
287
- .and()
288
- .addResource('gravity', { value: 9.8 });
419
+ .build();
289
420
 
290
- const renderBundle = new Bundle<GameComponents>('render')
291
- .addSystem('renderer')
292
- .addQuery('sprites', { with: ['position', 'sprite'] })
293
- .setProcess((queries) => {
294
- // Render sprites
295
- })
296
- .and();
421
+ await world.initialize();
422
+ ```
297
423
 
298
- // Create world with bundles
299
- const game = ECSpresso.create<GameComponents, {}, GameResources>()
300
- .withBundle(physicsBundle)
301
- .withBundle(renderBundle)
302
- .build();
424
+ **Post-Update Hooks** -- Register callbacks that run between the `postUpdate` and `render` phases:
425
+
426
+ ```typescript
427
+ // Returns unsubscribe function; multiple hooks run in registration order
428
+ const unsubscribe = world.onPostUpdate((ecs, deltaTime) => {
429
+ console.log(`Frame completed in ${deltaTime}s`);
430
+ });
431
+
432
+ unsubscribe();
303
433
  ```
304
434
 
305
- ### Events
435
+ ## Events
306
436
 
307
- Use events for decoupled system communication:
437
+ Use events for decoupled system communication. Events work across all features -- hierarchy changes, asset loading, timer completion, and custom game events all use the same system.
308
438
 
309
439
  ```typescript
310
440
  interface Events {
311
441
  playerDied: { playerId: number };
312
442
  levelComplete: { score: number };
443
+ // Hierarchy events (if using entity hierarchy)
444
+ hierarchyChanged: {
445
+ entityId: number;
446
+ oldParent: number | null;
447
+ newParent: number | null;
448
+ };
313
449
  }
314
450
 
315
451
  const world = new ECSpresso<Components, Events>();
316
452
 
317
- // Subscribe to events with on() - returns unsubscribe function
453
+ // Subscribe - returns unsubscribe function
318
454
  const unsubscribe = world.on('playerDied', (data) => {
319
455
  console.log(`Player ${data.playerId} died`);
320
456
  });
321
-
322
- // Unsubscribe when done
323
457
  unsubscribe();
324
458
 
325
- // Or unsubscribe by callback reference with off()
326
- const handler = (data) => console.log(`Level complete! Score: ${data.score}`);
459
+ // Or unsubscribe by callback reference
460
+ const handler = (data) => console.log(`Score: ${data.score}`);
327
461
  world.on('levelComplete', handler);
328
462
  world.off('levelComplete', handler);
329
463
 
@@ -332,8 +466,7 @@ world.addSystem('gameLogic')
332
466
  .setEventHandlers({
333
467
  playerDied: {
334
468
  handler: (data, ecs) => {
335
- console.log(`Player ${data.playerId} died`);
336
- // Respawn logic here
469
+ // Respawn logic
337
470
  }
338
471
  }
339
472
  })
@@ -343,302 +476,276 @@ world.addSystem('gameLogic')
343
476
  world.eventBus.publish('playerDied', { playerId: 1 });
344
477
  ```
345
478
 
346
- ### Resource Factories
479
+ **Built-in events**: `hierarchyChanged` (entity parent changes), `assetLoaded` / `assetFailed` / `assetGroupProgress` / `assetGroupLoaded` (asset loading), and timer `onComplete` events (see [Bundles](#bundles)).
347
480
 
348
- Create resources lazily with factory functions:
481
+ ## Entity Hierarchy
482
+
483
+ Create parent-child relationships between entities for scene graphs, UI trees, or skeletal hierarchies:
349
484
 
350
485
  ```typescript
351
- interface Resources {
352
- config: { difficulty: string; soundEnabled: boolean };
353
- database: Database;
354
- cache: { db: Database };
355
- }
486
+ const player = world.spawn({ position: { x: 0, y: 0 } });
356
487
 
357
- const world = new ECSpresso<Components, {}, Resources>();
488
+ // Create child entity
489
+ const weapon = world.spawnChild(player.id, { position: { x: 10, y: 0 } });
490
+
491
+ // Or set parent on existing entity
492
+ const shield = world.spawn({ position: { x: -10, y: 0 } });
493
+ world.setParent(shield.id, player.id);
358
494
 
359
- // Sync factory
360
- world.addResource('config', () => ({
361
- difficulty: 'normal',
362
- soundEnabled: true
363
- }));
495
+ // Orphan an entity
496
+ world.removeParent(shield.id);
497
+ ```
364
498
 
365
- // Async factory
366
- world.addResource('database', async () => {
367
- return await connectToDatabase();
368
- });
499
+ ### Traversal
369
500
 
370
- // Factory with dependencies - initialized after dependencies are ready
371
- world.addResource('cache', {
372
- dependsOn: ['database'],
373
- factory: (ecs) => ({
374
- db: ecs.getResource('database')
375
- })
501
+ | Method | Returns | Description |
502
+ |--------|---------|-------------|
503
+ | `getParent(id)` | `number \| null` | Parent entity ID |
504
+ | `getChildren(id)` | `number[]` | Direct children |
505
+ | `getAncestors(id)` | `number[]` | Entity up to root |
506
+ | `getDescendants(id)` | `number[]` | Depth-first order |
507
+ | `getRoot(id)` | `number` | Root of the hierarchy |
508
+ | `getSiblings(id)` | `number[]` | Other children of same parent |
509
+ | `getRootEntities()` | `number[]` | All root entities |
510
+ | `getChildAt(id, index)` | `number` | Child at index |
511
+ | `getChildIndex(parentId, childId)` | `number` | Index of child |
512
+ | `isDescendantOf(id, ancestorId)` | `boolean` | Relationship check |
513
+ | `isAncestorOf(id, descendantId)` | `boolean` | Relationship check |
514
+
515
+ ### Parent-First Traversal
516
+
517
+ Iterate the hierarchy with guaranteed parent-first order (useful for transform propagation):
518
+
519
+ ```typescript
520
+ // Callback-based traversal
521
+ world.forEachInHierarchy((entityId, parentId, depth) => {
522
+ // Parents are always visited before their children
376
523
  });
377
524
 
378
- // Initialize all resources (respects dependency order)
379
- await world.initializeResources();
380
- ```
525
+ // Filter to specific subtrees
526
+ world.forEachInHierarchy(callback, { roots: [root.id] });
381
527
 
382
- **Dependency Features:**
383
- - Resources are initialized in topological order (dependencies first)
384
- - Circular dependencies throw a descriptive error at initialization time
385
- - Existing patterns (direct values, simple factories) work unchanged
528
+ // Generator-based (supports early termination)
529
+ for (const { entityId, parentId, depth } of world.hierarchyIterator()) {
530
+ if (depth > 2) break;
531
+ }
532
+ ```
386
533
 
387
- ### Resource Builder
534
+ ### Cascade Deletion
388
535
 
389
- Add resources fluently during ECSpresso construction using `withResource()`:
536
+ When removing entities, descendants are automatically removed by default:
390
537
 
391
538
  ```typescript
392
- const world = ECSpresso
393
- .create<Components, Events, Resources>()
394
- .withBundle(physicsBundle)
395
- .withResource('config', { debug: true, maxEntities: 1000 })
396
- .withResource('score', () => ({ value: 0 }))
397
- .withResource('cache', {
398
- dependsOn: ['database'],
399
- factory: (ecs) => createCache(ecs.getResource('database'))
400
- })
401
- .build();
539
+ world.removeEntity(parent.id);
540
+ // All descendants are removed
541
+
542
+ // To orphan children instead:
543
+ world.removeEntity(parent.id, { cascade: false });
402
544
  ```
403
545
 
404
- This chains naturally with `withBundle()`, `withAssets()`, and `withScreens()`.
546
+ Hierarchy changes emit the `hierarchyChanged` event (see [Events](#events)).
405
547
 
406
- ### Resource Disposal
548
+ **World position pattern**: `worldPos = localPos + parent.worldPos`. A parent's world position already includes all grandparents, so each entity only needs to combine its local position with its immediate parent's world position. The Transform bundle implements this automatically.
407
549
 
408
- Resources can define cleanup logic with `onDispose` callbacks, useful for removing event listeners, closing connections, or releasing resources:
550
+ ## Change Detection
409
551
 
410
- ```typescript
411
- // Factory with disposal callback
412
- world.addResource('keyboard', {
413
- factory: () => {
414
- const handler = (e: KeyboardEvent) => { /* ... */ };
415
- window.addEventListener('keydown', handler);
416
- return { handler };
417
- },
418
- onDispose: (resource) => {
419
- window.removeEventListener('keydown', resource.handler);
420
- }
421
- });
552
+ ECSpresso tracks component changes using a per-system monotonic sequence. Each `markChanged` call increments a global counter and stamps the component with a unique sequence number. Each system tracks the highest sequence it has seen; on its next execution, it only processes marks with a sequence greater than its last-seen value. This means each mark is processed exactly once per system, and marks expire after a single update cycle.
422
553
 
423
- // Or with the builder pattern
424
- const world = ECSpresso
425
- .create<Components, Events, Resources>()
426
- .withResource('database', {
427
- factory: async () => await connectToDatabase(),
428
- onDispose: async (db) => await db.close()
429
- })
430
- .build();
554
+ ### Marking Changes
431
555
 
432
- // Dispose a single resource
433
- await world.disposeResource('keyboard');
556
+ Components are automatically marked as changed when added via `spawn()`, `addComponent()`, or `addComponents()`. For in-place mutations, call `markChanged` explicitly:
434
557
 
435
- // Dispose all resources in reverse dependency order
436
- // (dependents are disposed before their dependencies)
437
- await world.disposeResources();
558
+ ```typescript
559
+ const position = world.entityManager.getComponent(entity.id, 'position');
560
+ if (position) {
561
+ position.x += 10;
562
+ world.markChanged(entity.id, 'position');
563
+ }
438
564
  ```
439
565
 
440
- **Disposal Features:**
441
- - `onDispose` receives the resource value and the ECSpresso instance as context
442
- - `disposeResources()` disposes in reverse topological order (dependents first)
443
- - Only initialized resources have their `onDispose` called
444
- - Supports both sync and async disposal callbacks
445
- - `removeResource()` still exists for removal without disposal
446
-
447
- ### System Lifecycle
566
+ ### Changed Query Filter
448
567
 
449
- Systems can have initialization and cleanup hooks:
568
+ Add `changed` to a query definition to filter entities to only those whose specified components changed since the system last ran:
450
569
 
451
570
  ```typescript
452
- world.addSystem('gameSystem')
453
- .setOnInitialize(async (ecs) => {
454
- // One-time setup
455
- console.log('System starting...');
571
+ world.addSystem('render-sync')
572
+ .addQuery('moved', {
573
+ with: ['position', 'sprite'],
574
+ changed: ['position'], // Only entities whose position changed this tick
456
575
  })
457
- .setOnDetach((ecs) => {
458
- // Cleanup when system is removed
459
- console.log('System shutting down...');
576
+ .setProcess((queries) => {
577
+ for (const entity of queries.moved) {
578
+ syncSpritePosition(entity);
579
+ }
460
580
  })
461
581
  .build();
462
-
463
- // Initialize all systems
464
- await world.initialize();
465
582
  ```
466
583
 
467
- ### Post-Update Hooks
584
+ When multiple components are listed in `changed`, entities matching **any** of them are included (OR semantics).
468
585
 
469
- Register callbacks that run after all systems have processed during `update()`:
586
+ ### Sequence Timing
470
587
 
471
- ```typescript
472
- // Register a post-update hook - returns unsubscribe function
473
- const unsubscribe = world.onPostUpdate((ecs, deltaTime) => {
474
- // Runs after all systems in update()
475
- // Useful for cleanup, state sync, or debug logging
476
- console.log(`Frame completed in ${deltaTime}s`);
477
- });
588
+ - Marks made between updates are visible to all systems on the next update
589
+ - Spawn auto-marks are visible on the first update
590
+ - Marks from earlier phases are visible to later phases within the same frame
591
+ - Within a phase, a higher-priority system's marks are visible to lower-priority systems
592
+ - Each mark is processed exactly once per system (single-update expiry)
478
593
 
479
- // Multiple hooks run in registration order
480
- world.onPostUpdate((ecs) => {
481
- // First hook
482
- });
483
- world.onPostUpdate((ecs) => {
484
- // Second hook
485
- });
594
+ For manual change detection outside of system queries:
486
595
 
487
- // Unsubscribe when no longer needed
488
- unsubscribe();
596
+ ```typescript
597
+ const em = ecs.entityManager;
598
+ if (em.getChangeSeq(entity.id, 'localTransform') > ecs.changeThreshold) {
599
+ // Component changed since last system execution (or since last update if between updates)
600
+ }
489
601
  ```
490
602
 
491
- ### Entity Hierarchy
603
+ **Deferred marking**: `ecs.commands.markChanged(entity.id, 'position')` queues a mark for command buffer playback.
492
604
 
493
- Create parent-child relationships between entities for scene graphs, UI trees, or skeletal hierarchies:
605
+ **Built-in bundle usage**: Movement marks `localTransform` (fixedUpdate) Transform propagation reads `localTransform` changed, writes+marks `worldTransform` (postUpdate) → Renderer reads `worldTransform` changed (render).
494
606
 
495
- ```typescript
496
- const world = new ECSpresso<Components>();
607
+ ## Command Buffer
497
608
 
498
- // Create a parent entity
499
- const player = world.spawn({
500
- position: { x: 0, y: 0 }
501
- });
609
+ Queue structural changes during system execution that execute between phases. This prevents issues when modifying entities during iteration.
502
610
 
503
- // Create a child entity using spawnChild
504
- const weapon = world.spawnChild(player.id, {
505
- position: { x: 10, y: 0 } // Relative to parent
506
- });
507
-
508
- // Or set parent on existing entity
509
- const shield = world.spawn({ position: { x: -10, y: 0 } });
510
- world.setParent(shield.id, player.id);
511
-
512
- // Query relationships
513
- world.getParent(weapon.id); // player.id
514
- world.getChildren(player.id); // [weapon.id, shield.id]
515
-
516
- // Orphan an entity (remove from parent)
517
- world.removeParent(shield.id);
518
- world.getParent(shield.id); // null
611
+ ```typescript
612
+ world.addSystem('combat')
613
+ .addQuery('enemies', { with: ['enemy', 'health'] })
614
+ .setProcess((queries, dt, ecs) => {
615
+ for (const entity of queries.enemies) {
616
+ if (entity.components.health.value <= 0) {
617
+ ecs.commands.removeEntity(entity.id);
618
+ ecs.commands.spawn({
619
+ position: entity.components.position,
620
+ explosion: true,
621
+ });
622
+ }
623
+ }
624
+ })
625
+ .build();
519
626
  ```
520
627
 
521
- #### Traversal Methods
522
-
523
- Navigate the hierarchy tree with traversal utilities:
628
+ ### Available Commands
524
629
 
525
630
  ```typescript
526
- // Build a hierarchy: root -> child -> grandchild
527
- const root = world.spawn({ position: { x: 0, y: 0 } });
528
- const child = world.spawnChild(root.id, { position: { x: 10, y: 0 } });
529
- const grandchild = world.spawnChild(child.id, { position: { x: 20, y: 0 } });
530
-
531
- // Ancestors (from entity up to root)
532
- world.getAncestors(grandchild.id); // [child.id, root.id]
533
-
534
- // Descendants (depth-first order)
535
- world.getDescendants(root.id); // [child.id, grandchild.id]
536
-
537
- // Get root of any entity
538
- world.getRoot(grandchild.id); // root.id
631
+ // Entity operations
632
+ ecs.commands.spawn({ position: { x: 0, y: 0 } });
633
+ ecs.commands.spawnChild(parentId, { position: { x: 10, y: 0 } });
634
+ ecs.commands.removeEntity(entityId);
635
+ ecs.commands.removeEntity(entityId, { cascade: false });
539
636
 
540
- // Siblings (other children of same parent)
541
- const child2 = world.spawnChild(root.id, { position: { x: -10, y: 0 } });
542
- world.getSiblings(child.id); // [child2.id]
637
+ // Component operations
638
+ ecs.commands.addComponent(entityId, 'velocity', { x: 5, y: 0 });
639
+ ecs.commands.addComponents(entityId, { velocity: { x: 5, y: 0 }, health: { value: 100 } });
640
+ ecs.commands.removeComponent(entityId, 'velocity');
543
641
 
544
- // Relationship checks
545
- world.isDescendantOf(grandchild.id, root.id); // true
546
- world.isAncestorOf(root.id, grandchild.id); // true
642
+ // Hierarchy operations
643
+ ecs.commands.setParent(childId, parentId);
644
+ ecs.commands.removeParent(childId);
547
645
 
548
- // All root entities (entities with children but no parent)
549
- world.getRootEntities(); // [root.id]
646
+ // Change detection
647
+ ecs.commands.markChanged(entityId, 'position');
550
648
 
551
- // Child ordering
552
- world.getChildAt(root.id, 0); // child.id
553
- world.getChildIndex(root.id, child2.id); // 1
649
+ // Utility
650
+ ecs.commands.length; // Number of queued commands
651
+ ecs.commands.clear(); // Discard all queued commands
554
652
  ```
555
653
 
556
- #### Parent-First Traversal
654
+ Commands execute in FIFO order. If a command fails (e.g., entity doesn't exist), it logs a warning and continues with remaining commands.
557
655
 
558
- Iterate the hierarchy with guaranteed parent-first order (useful for transform propagation):
656
+ ## Bundles
657
+
658
+ Organize related systems and resources into reusable bundles:
559
659
 
560
660
  ```typescript
561
- // Callback-based traversal
562
- world.forEachInHierarchy((entityId, parentId, depth) => {
563
- // Parents are always visited before their children
564
- console.log(`Entity ${entityId} at depth ${depth}, parent: ${parentId}`);
565
- });
661
+ import { Bundle } from 'ecspresso';
566
662
 
567
- // Filter to specific subtrees
568
- world.forEachInHierarchy(
569
- (entityId, parentId, depth) => {
570
- // Only visits entities under root.id
571
- },
572
- { roots: [root.id] }
573
- );
663
+ const physicsBundle = new Bundle<GameComponents, {}, GameResources>('physics')
664
+ .addSystem('applyVelocity')
665
+ .addQuery('moving', { with: ['position', 'velocity'] })
666
+ .setProcess((queries, deltaTime) => {
667
+ for (const entity of queries.moving) {
668
+ entity.components.position.x += entity.components.velocity.x * deltaTime;
669
+ entity.components.position.y += entity.components.velocity.y * deltaTime;
670
+ }
671
+ })
672
+ .and()
673
+ .addSystem('applyGravity')
674
+ .addQuery('falling', { with: ['velocity'] })
675
+ .setProcess((queries, deltaTime, ecs) => {
676
+ const gravity = ecs.getResource('gravity');
677
+ for (const entity of queries.falling) {
678
+ entity.components.velocity.y += gravity.value * deltaTime;
679
+ }
680
+ })
681
+ .and()
682
+ .addResource('gravity', { value: 9.8 });
574
683
 
575
- // Generator-based traversal (supports early termination)
576
- for (const { entityId, parentId, depth } of world.hierarchyIterator()) {
577
- if (depth > 2) break; // Stop at depth 2
578
- console.log(entityId);
579
- }
684
+ // Register bundles with the world
685
+ const game = ECSpresso.create<GameComponents, {}, GameResources>()
686
+ .withBundle(physicsBundle)
687
+ .build();
580
688
  ```
581
689
 
582
- #### Cascade Deletion
690
+ ### Built-in Bundles
583
691
 
584
- When removing entities, descendants are automatically removed by default:
585
-
586
- ```typescript
587
- const parent = world.spawn({ position: { x: 0, y: 0 } });
588
- const child = world.spawnChild(parent.id, { position: { x: 10, y: 0 } });
589
- const grandchild = world.spawnChild(child.id, { position: { x: 20, y: 0 } });
590
-
591
- // Remove parent - cascades to all descendants
592
- world.removeEntity(parent.id);
593
- world.entityManager.getEntity(child.id); // undefined
594
- world.entityManager.getEntity(grandchild.id); // undefined
692
+ | Bundle | Import | Default Phase | Description |
693
+ |--------|--------|---------------|-------------|
694
+ | **Timers** | `ecspresso/bundles/utils/timers` | `preUpdate` | ECS-native timers with event-based completion |
695
+ | **Movement** | `ecspresso/bundles/utils/movement` | `fixedUpdate` | Velocity-based movement integration |
696
+ | **Transform** | `ecspresso/bundles/utils/transform` | `postUpdate` | Hierarchical transform propagation (local/world transforms) |
697
+ | **Bounds** | `ecspresso/bundles/utils/bounds` | `postUpdate` | Screen bounds enforcement (destroy, clamp, wrap) |
698
+ | **Collision** | `ecspresso/bundles/utils/collision` | `postUpdate` | Layer-based AABB/circle collision detection with events |
699
+ | **2D Renderer** | `ecspresso/bundles/renderers/renderer2D` | `render` | Automated PixiJS scene graph wiring |
595
700
 
596
- // To orphan children instead of deleting them:
597
- world.removeEntity(parent.id, { cascade: false });
598
- // Children still exist but have no parent
599
- ```
701
+ Each bundle accepts a `phase` option to override its default.
600
702
 
601
- #### Hierarchy Events
703
+ ### Timer Bundle
602
704
 
603
- React to hierarchy changes with the `hierarchyChanged` event:
705
+ The timer bundle provides ECS-native timers that follow the "data, not callbacks" philosophy. Timers are components processed each frame, with optional event-based completion notifications.
604
706
 
605
707
  ```typescript
708
+ import {
709
+ createTimerBundle, createTimer, createRepeatingTimer,
710
+ type TimerComponentTypes, type TimerEventData
711
+ } from 'ecspresso/bundles/utils/timers';
712
+
713
+ // Events used with onComplete must have TimerEventData payload
606
714
  interface Events {
607
- hierarchyChanged: {
608
- entityId: number;
609
- oldParent: number | null;
610
- newParent: number | null;
611
- };
715
+ hideMessage: TimerEventData; // { entityId, duration, elapsed }
716
+ spawnWave: TimerEventData;
612
717
  }
613
718
 
614
- const world = new ECSpresso<Components, Events>();
719
+ interface Components extends TimerComponentTypes<Events> {
720
+ position: { x: number; y: number };
721
+ }
615
722
 
616
- world.on('hierarchyChanged', (data) => {
617
- if (data.newParent !== null) {
618
- console.log(`Entity ${data.entityId} attached to ${data.newParent}`);
619
- } else {
620
- console.log(`Entity ${data.entityId} detached from ${data.oldParent}`);
621
- }
622
- });
723
+ const world = ECSpresso
724
+ .create<Components, Events>()
725
+ .withBundle(createTimerBundle<Events>())
726
+ .build();
727
+
728
+ // One-shot timer (poll justFinished or use onComplete event)
729
+ world.spawn({ ...createTimer<Events>(2.0), position: { x: 0, y: 0 } });
730
+ world.spawn({ ...createTimer<Events>(1.5, { onComplete: 'hideMessage' }) });
623
731
 
624
- // Events fire on setParent, removeParent, and spawnChild
625
- world.setParent(child.id, parent.id); // Emits hierarchyChanged
732
+ // Repeating timer
733
+ world.spawn({ ...createRepeatingTimer<Events>(5.0, { onComplete: 'spawnWave' }) });
626
734
  ```
627
735
 
628
- ### Asset Management
736
+ Timer components expose `elapsed`, `duration`, `repeat`, `active`, `justFinished`, and optional `onComplete` for runtime control.
737
+
738
+ ## Asset Management
629
739
 
630
740
  Manage game assets with eager/lazy loading, groups, and progress tracking:
631
741
 
632
742
  ```typescript
633
- // Define asset types
634
743
  type Assets = {
635
744
  playerTexture: { data: ImageBitmap };
636
- enemyTexture: { data: ImageBitmap };
637
745
  level1Music: { buffer: AudioBuffer };
638
746
  level1Background: { data: ImageBitmap };
639
747
  };
640
748
 
641
- // Create world with assets using the builder pattern
642
749
  const game = ECSpresso.create<Components, Events, Resources, Assets>()
643
750
  .withAssets(assets => assets
644
751
  // Eager assets - loaded automatically during initialize()
@@ -646,110 +753,55 @@ const game = ECSpresso.create<Components, Events, Resources, Assets>()
646
753
  const img = await loadImage('player.png');
647
754
  return { data: img };
648
755
  })
649
- .add('enemyTexture', async () => {
650
- const img = await loadImage('enemy.png');
651
- return { data: img };
652
- })
653
756
  // Lazy asset group - loaded on demand
654
757
  .addGroup('level1', {
655
- level1Music: async () => {
656
- const buffer = await loadAudio('level1.mp3');
657
- return { buffer };
658
- },
659
- level1Background: async () => {
660
- const img = await loadImage('level1-bg.png');
661
- return { data: img };
662
- },
758
+ level1Music: async () => ({ buffer: await loadAudio('level1.mp3') }),
759
+ level1Background: async () => ({ data: await loadImage('level1-bg.png') }),
663
760
  })
664
761
  )
665
762
  .build();
666
763
 
667
- // Initialize loads eager assets automatically
668
- await game.initialize();
669
-
670
- // Access loaded assets
671
- const player = game.getAsset('playerTexture');
672
-
673
- // Check if asset is loaded
674
- if (game.isAssetLoaded('enemyTexture')) {
675
- const enemy = game.getAsset('enemyTexture');
676
- }
677
-
678
- // Load asset groups on demand (e.g., when entering a level)
679
- await game.loadAssetGroup('level1');
680
-
681
- // Track loading progress
682
- const progress = game.getAssetGroupProgress('level1'); // 0-1
683
-
684
- // Check if group is fully loaded
685
- if (game.isAssetGroupLoaded('level1')) {
686
- const music = game.getAsset('level1Music');
687
- }
688
- ```
689
-
690
- #### Asset Events
691
-
692
- React to asset loading with built-in events:
764
+ await game.initialize(); // Loads eager assets
765
+ const player = game.getAsset('playerTexture'); // Access loaded asset
766
+ game.isAssetLoaded('playerTexture'); // Check if loaded
693
767
 
694
- ```typescript
695
- game.addSystem('loadingUI')
696
- .setEventHandlers({
697
- assetLoaded: {
698
- handler: (data) => console.log(`Loaded: ${data.key}`)
699
- },
700
- assetFailed: {
701
- handler: (data) => console.error(`Failed: ${data.key}`, data.error)
702
- },
703
- assetGroupProgress: {
704
- handler: (data) => {
705
- console.log(`${data.group}: ${data.loaded}/${data.total}`);
706
- }
707
- },
708
- assetGroupLoaded: {
709
- handler: (data) => console.log(`Group ready: ${data.group}`)
710
- }
711
- })
712
- .build();
768
+ await game.loadAssetGroup('level1'); // Load group on demand
769
+ game.getAssetGroupProgress('level1'); // 0-1 progress
770
+ game.isAssetGroupLoaded('level1'); // Check if group is ready
713
771
  ```
714
772
 
715
- #### Systems with Asset Requirements
716
-
717
773
  Systems can declare required assets and will only run when those assets are loaded:
718
774
 
719
775
  ```typescript
720
776
  game.addSystem('gameplay')
721
- .requiresAssets(['playerTexture', 'enemyTexture'])
777
+ .requiresAssets(['playerTexture'])
722
778
  .setProcess((queries, dt, ecs) => {
723
- // This only runs when both assets are loaded
724
779
  const player = ecs.getAsset('playerTexture');
725
780
  })
726
781
  .build();
727
782
  ```
728
783
 
729
- ### Screen Management
784
+ Asset events (`assetLoaded`, `assetFailed`, `assetGroupProgress`, `assetGroupLoaded`) are available through the event system -- see [Events](#events).
785
+
786
+ ## Screen Management
730
787
 
731
788
  Manage game states/screens with transitions and overlay support:
732
789
 
733
790
  ```typescript
734
791
  import type { ScreenDefinition } from 'ecspresso';
735
792
 
736
- // Define screen types with config and state
737
793
  type Screens = {
738
794
  menu: ScreenDefinition<
739
795
  Record<string, never>, // Config (passed when entering)
740
796
  { selectedOption: number } // State (mutable during screen)
741
797
  >;
742
798
  gameplay: ScreenDefinition<
743
- { difficulty: string; level: number }, // Config
744
- { score: number; isPaused: boolean } // State
745
- >;
746
- pause: ScreenDefinition<
747
- Record<string, never>,
748
- Record<string, never>
799
+ { difficulty: string; level: number },
800
+ { score: number; isPaused: boolean }
749
801
  >;
802
+ pause: ScreenDefinition<Record<string, never>, Record<string, never>>;
750
803
  };
751
804
 
752
- // Create world with screens
753
805
  const game = ECSpresso.create<Components, Events, Resources, {}, Screens>()
754
806
  .withScreens(screens => screens
755
807
  .add('menu', {
@@ -761,73 +813,43 @@ const game = ECSpresso.create<Components, Events, Resources, {}, Screens>()
761
813
  initialState: () => ({ score: 0, isPaused: false }),
762
814
  onEnter: (config) => console.log(`Starting level ${config.level}`),
763
815
  onExit: () => console.log('Gameplay ended'),
764
- // Require assets before screen can be entered
765
816
  requiredAssetGroups: ['level1'],
766
817
  })
767
818
  .add('pause', {
768
819
  initialState: () => ({}),
769
- onEnter: () => console.log('Paused'),
770
- onExit: () => console.log('Resumed'),
771
820
  })
772
821
  )
773
822
  .build();
774
823
 
775
824
  await game.initialize();
776
-
777
- // Set initial screen
778
- await game.setScreen('menu', {});
779
-
780
- // Transition to gameplay (clears screen stack)
781
- await game.setScreen('gameplay', { difficulty: 'hard', level: 1 });
782
-
783
- // Push overlay screen (adds to stack, previous screen stays active)
784
- await game.pushScreen('pause', {});
785
-
786
- // Pop overlay (returns to previous screen)
787
- await game.popScreen();
788
-
789
- // Access current screen info
790
- const current = game.getCurrentScreen(); // 'gameplay'
791
- const config = game.getScreenConfig(); // { difficulty: 'hard', level: 1 }
792
- const state = game.getScreenState(); // { score: 0, isPaused: false }
793
-
794
- // Update screen state
825
+ await game.setScreen('menu', {}); // Set initial screen
826
+ await game.setScreen('gameplay', { difficulty: 'hard', level: 1 }); // Transition
827
+ await game.pushScreen('pause', {}); // Push overlay
828
+ await game.popScreen(); // Pop overlay
829
+
830
+ const current = game.getCurrentScreen(); // 'gameplay'
831
+ const config = game.getScreenConfig(); // { difficulty: 'hard', level: 1 }
832
+ const state = game.getScreenState(); // { score: 0, isPaused: false }
795
833
  game.updateScreenState({ score: 100 });
796
834
  ```
797
835
 
798
- #### Screen-Scoped Systems
799
-
800
- Systems can be restricted to run only in specific screens:
836
+ ### Screen-Scoped Systems
801
837
 
802
838
  ```typescript
803
- // Only runs when 'menu' is the current screen
804
839
  game.addSystem('menuUI')
805
- .inScreens(['menu'])
840
+ .inScreens(['menu']) // Only runs in 'menu'
806
841
  .setProcess((queries, dt, ecs) => {
807
- const state = ecs.getScreenState();
808
- renderMenu(state.selectedOption);
842
+ renderMenu(ecs.getScreenState().selectedOption);
809
843
  })
810
844
  .build();
811
845
 
812
- // Only runs in 'gameplay' screen
813
- game.addSystem('scoring')
814
- .inScreens(['gameplay'])
815
- .setProcess((queries, dt, ecs) => {
816
- const state = ecs.getScreenState();
817
- ecs.updateScreenState({ score: state.score + 1 });
818
- })
819
- .build();
820
-
821
- // Runs in all screens EXCEPT 'pause'
822
846
  game.addSystem('animations')
823
- .excludeScreens(['pause'])
824
- .setProcess(() => {
825
- // Animations continue except when paused
826
- })
847
+ .excludeScreens(['pause']) // Runs in all screens except 'pause'
848
+ .setProcess(() => { /* ... */ })
827
849
  .build();
828
850
  ```
829
851
 
830
- #### Screen Resource
852
+ ### Screen Resource
831
853
 
832
854
  Access screen state through the `$screen` resource:
833
855
 
@@ -835,20 +857,13 @@ Access screen state through the `$screen` resource:
835
857
  game.addSystem('ui')
836
858
  .setProcess((queries, dt, ecs) => {
837
859
  const screen = ecs.getResource('$screen');
838
-
839
- console.log(screen.current); // Current screen name
840
- console.log(screen.config); // Current screen config
841
- console.log(screen.state); // Current screen state (mutable)
842
- console.log(screen.isOverlay); // true if screen was pushed
843
- console.log(screen.stackDepth); // Number of screens in stack
844
-
845
- // Check screen status
846
- if (screen.isCurrent('gameplay')) {
847
- // ...
848
- }
849
- if (screen.isActive('menu')) {
850
- // true if menu is current OR in the stack
851
- }
860
+ screen.current; // Current screen name
861
+ screen.config; // Current screen config
862
+ screen.state; // Current screen state (mutable)
863
+ screen.isOverlay; // true if screen was pushed
864
+ screen.stackDepth; // Number of screens in stack
865
+ screen.isCurrent('gameplay'); // Check current screen
866
+ screen.isActive('menu'); // true if in current or stack
852
867
  })
853
868
  .build();
854
869
  ```
@@ -857,336 +872,58 @@ game.addSystem('ui')
857
872
 
858
873
  ECSpresso provides comprehensive TypeScript support:
859
874
 
860
- ### Component Type Safety
861
875
  ```typescript
862
876
  // ✅ Valid
863
877
  world.entityManager.addComponent(entity.id, 'position', { x: 0, y: 0 });
864
878
 
865
- // ❌ TypeScript error - invalid component
879
+ // ❌ TypeScript error - invalid component name
866
880
  world.entityManager.addComponent(entity.id, 'invalid', { data: 'bad' });
867
881
 
868
882
  // ❌ TypeScript error - wrong component shape
869
883
  world.entityManager.addComponent(entity.id, 'position', { x: 0 }); // missing y
870
- ```
871
884
 
872
- ### Query Type Safety
873
- ```typescript
885
+ // Query type safety - TypeScript knows which components exist
874
886
  world.addSystem('example')
875
887
  .addQuery('moving', { with: ['position', 'velocity'] })
876
888
  .setProcess((queries) => {
877
889
  for (const entity of queries.moving) {
878
- // ✅ TypeScript knows these exist
879
- entity.components.position.x;
880
- entity.components.velocity.y;
881
-
882
- // ❌ TypeScript error - not guaranteed to exist
883
- entity.components.health.value;
890
+ entity.components.position.x; // ✅ guaranteed
891
+ entity.components.health.value; // ❌ not in query
884
892
  }
885
893
  })
886
894
  .build();
887
- ```
888
-
889
- ### Bundle Type Compatibility
890
- ```typescript
891
- // ✅ Compatible bundles merge cleanly
892
- const bundle1 = new Bundle<{position: {x: number, y: number}}>('bundle1');
893
- const bundle2 = new Bundle<{velocity: {x: number, y: number}}>('bundle2');
894
895
 
896
+ // Bundle type compatibility - conflicting types error at compile time
897
+ const bundle1 = new Bundle<{position: {x: number, y: number}}>('b1');
898
+ const bundle2 = new Bundle<{velocity: {x: number, y: number}}>('b2');
895
899
  const world = ECSpresso.create()
896
900
  .withBundle(bundle1)
897
- .withBundle(bundle2) // Types merge successfully
898
- .build();
899
-
900
- // ❌ Conflicting types error at compile time
901
- const conflictingBundle = new Bundle<{position: string}>('conflict');
902
- world.withBundle(conflictingBundle); // TypeScript prevents this
903
- ```
904
-
905
- ## Component Callbacks
906
-
907
- React to component changes with callbacks. Both methods return an unsubscribe function:
908
-
909
- ```typescript
910
- // Listen for component additions - returns unsubscribe function
911
- const unsubAdd = world.onComponentAdded('health', (value, entity) => {
912
- console.log(`Health added to entity ${entity.id}:`, value);
913
- });
914
-
915
- // Listen for component removals
916
- const unsubRemove = world.onComponentRemoved('health', (oldValue, entity) => {
917
- console.log(`Health removed from entity ${entity.id}:`, oldValue);
918
- });
919
-
920
- // Unsubscribe when done
921
- unsubAdd();
922
- unsubRemove();
923
-
924
- // Also available on entityManager directly
925
- world.entityManager.onComponentAdded('position', (value, entity) => {
926
- // ...
927
- });
928
- ```
929
-
930
- ## Reactive Queries
931
-
932
- Get callbacks when entities enter or exit a query match. Unlike regular queries that you poll during `update()`, reactive queries push notifications when the entity's components change:
933
-
934
- ```typescript
935
- // Add a reactive query with enter/exit callbacks
936
- world.addReactiveQuery('enemies', {
937
- with: ['position', 'enemy'],
938
- without: ['dead'],
939
- onEnter: (entity) => {
940
- // Called when entity starts matching the query
941
- console.log(`Enemy ${entity.id} appeared at`, entity.components.position);
942
- spawnHealthBar(entity.id);
943
- },
944
- onExit: (entityId) => {
945
- // Called when entity stops matching (receives ID since entity may be removed)
946
- console.log(`Enemy ${entityId} gone`);
947
- removeHealthBar(entityId);
948
- },
949
- });
950
-
951
- // Triggers: spawning matching entity, adding required component,
952
- // removing excluded component
953
- const enemy = world.spawn({ position: { x: 0, y: 0 }, enemy: true }); // onEnter fires
954
-
955
- // Triggers: removing required component, adding excluded component,
956
- // removing entity
957
- world.entityManager.addComponent(enemy.id, 'dead', true); // onExit fires
958
-
959
- // Existing matching entities trigger onEnter when query is added
960
- world.spawn({ position: { x: 10, y: 10 }, enemy: true });
961
- world.addReactiveQuery('positioned', {
962
- with: ['position'],
963
- onEnter: (entity) => { /* Called for all existing entities with position */ },
964
- });
965
-
966
- // Remove reactive query when no longer needed
967
- const removed = world.removeReactiveQuery('enemies'); // returns true if existed
968
- ```
969
-
970
- **Note:** Component replacement (calling `addComponent` with a component that already exists) does NOT trigger enter/exit callbacks since the entity's query match status doesn't change.
971
-
972
- ## Command Buffer
973
-
974
- The command buffer allows you to queue structural changes (entity creation, removal, component changes) that execute at the end of the update cycle. This prevents issues when modifying entities during system iteration.
975
-
976
- ```typescript
977
- // Queue commands during system execution
978
- world.addSystem('combat')
979
- .addQuery('enemies', { with: ['enemy', 'health'] })
980
- .setProcess((queries, dt, ecs) => {
981
- for (const entity of queries.enemies) {
982
- if (entity.components.health.value <= 0) {
983
- // Queue removal - doesn't execute immediately
984
- ecs.commands.removeEntity(entity.id);
985
-
986
- // Queue spawning an explosion
987
- ecs.commands.spawn({
988
- position: entity.components.position,
989
- explosion: true,
990
- });
991
- }
992
- }
993
- // Commands execute automatically at end of update()
994
- })
901
+ .withBundle(bundle2) // Types merge successfully
995
902
  .build();
996
903
  ```
997
904
 
998
- ### Available Commands
999
-
1000
- ```typescript
1001
- // Entity operations
1002
- ecs.commands.spawn({ position: { x: 0, y: 0 } });
1003
- ecs.commands.spawnChild(parentId, { position: { x: 10, y: 0 } });
1004
- ecs.commands.removeEntity(entityId);
1005
- ecs.commands.removeEntity(entityId, { cascade: false }); // Orphan children
1006
-
1007
- // Component operations
1008
- ecs.commands.addComponent(entityId, 'velocity', { x: 5, y: 0 });
1009
- ecs.commands.addComponents(entityId, { velocity: { x: 5, y: 0 }, health: { value: 100 } });
1010
- ecs.commands.removeComponent(entityId, 'velocity');
1011
-
1012
- // Hierarchy operations
1013
- ecs.commands.setParent(childId, parentId);
1014
- ecs.commands.removeParent(childId);
1015
-
1016
- // Utility
1017
- ecs.commands.length; // Number of queued commands
1018
- ecs.commands.clear(); // Discard all queued commands
1019
- ```
1020
-
1021
- Commands execute in FIFO order. If a command fails (e.g., entity doesn't exist), it logs a warning and continues with remaining commands.
1022
-
1023
- ## Built-in Bundles
1024
-
1025
- ECSpresso provides optional utility bundles for common game development needs:
1026
-
1027
- | Bundle | Import | Description |
1028
- |--------|--------|-------------|
1029
- | **Transform** | `ecspresso/bundles/utils/transform` | Hierarchical transform propagation (local/world transforms) |
1030
- | **Movement** | `ecspresso/bundles/utils/movement` | Velocity-based movement integration |
1031
- | **Bounds** | `ecspresso/bundles/utils/bounds` | Screen bounds enforcement (destroy, clamp, wrap) |
1032
- | **Collision** | `ecspresso/bundles/utils/collision` | Layer-based AABB/circle collision detection with events |
1033
- | **Timers** | `ecspresso/bundles/utils/timers` | ECS-native timers with event-based completion |
1034
- | **PixiJS Renderer** | `ecspresso/bundles/renderers/pixi` | Automated PixiJS scene graph wiring |
1035
-
1036
- ## Timer Bundle
1037
-
1038
- The timer bundle provides ECS-native timers that follow the "data, not callbacks" philosophy. Timers are components processed each frame, with optional event-based completion notifications.
1039
-
1040
- ```typescript
1041
- import {
1042
- createTimerBundle,
1043
- createTimer,
1044
- createRepeatingTimer,
1045
- type TimerComponentTypes,
1046
- type TimerEventData
1047
- } from 'ecspresso/bundles/utils/timers';
1048
-
1049
- // Define events that use TimerEventData payload
1050
- interface Events {
1051
- hideMessage: TimerEventData;
1052
- spawnWave: TimerEventData;
1053
- }
1054
-
1055
- // Extend components with timer support
1056
- interface Components extends TimerComponentTypes<Events> {
1057
- position: { x: number; y: number };
1058
- messageDisplay: true;
1059
- spawner: true;
1060
- }
1061
-
1062
- // Create world with timer bundle
1063
- const world = ECSpresso
1064
- .create<Components, Events, Resources>()
1065
- .withBundle(createTimerBundle<Events>())
1066
- .build();
1067
-
1068
- // One-shot timer without event (poll justFinished)
1069
- world.spawn({
1070
- ...createTimer<Events>(2.0),
1071
- messageDisplay: true,
1072
- });
1073
-
1074
- // One-shot timer with completion event
1075
- world.spawn({
1076
- ...createTimer<Events>(1.5, { onComplete: 'hideMessage' }),
1077
- messageDisplay: true,
1078
- });
1079
-
1080
- // Repeating timer with event
1081
- world.spawn({
1082
- ...createRepeatingTimer<Events>(5.0, { onComplete: 'spawnWave' }),
1083
- spawner: true,
1084
- });
1085
-
1086
- // Subscribe to timer events
1087
- world.on('hideMessage', (data) => {
1088
- console.log(`Timer on entity ${data.entityId} completed after ${data.elapsed}s`);
1089
- });
1090
- ```
1091
-
1092
- ### Timer Event Data
1093
-
1094
- Events used with timer `onComplete` must have `TimerEventData` as their payload type:
1095
-
1096
- ```typescript
1097
- interface TimerEventData {
1098
- entityId: number; // Entity the timer belongs to
1099
- duration: number; // Timer's configured duration
1100
- elapsed: number; // Actual elapsed time (may exceed duration slightly)
1101
- }
1102
- ```
1103
-
1104
- ### Polling vs Events
1105
-
1106
- You can use timers in two ways:
1107
-
1108
- ```typescript
1109
- // 1. Polling with justFinished flag
1110
- world.addSystem('timerPolling')
1111
- .addQuery('timers', { with: ['timer', 'messageDisplay'] })
1112
- .setProcess((queries) => {
1113
- for (const entity of queries.timers) {
1114
- if (entity.components.timer.justFinished) {
1115
- // Timer just completed this frame
1116
- hideMessage(entity.id);
1117
- }
1118
- }
1119
- })
1120
- .build();
1121
-
1122
- // 2. Event-based (decoupled)
1123
- world.addSystem('timerEvents')
1124
- .setEventHandlers({
1125
- hideMessage: {
1126
- handler: (data, ecs) => {
1127
- // React to timer completion
1128
- ecs.commands.removeEntity(data.entityId);
1129
- }
1130
- }
1131
- })
1132
- .build();
1133
- ```
1134
-
1135
- ### Timer Properties
1136
-
1137
- ```typescript
1138
- interface Timer {
1139
- elapsed: number; // Time accumulated (seconds)
1140
- duration: number; // Target duration (seconds)
1141
- repeat: boolean; // Whether timer repeats
1142
- active: boolean; // Whether timer is running
1143
- justFinished: boolean; // True for one frame after completion
1144
- onComplete?: string; // Event name to publish (optional)
1145
- }
1146
-
1147
- // Control timer at runtime
1148
- const entity = world.spawn({ ...createTimer<Events>(5.0), myTimer: true });
1149
- const timer = entity.components.timer;
1150
-
1151
- timer.active = false; // Pause
1152
- timer.active = true; // Resume
1153
- timer.elapsed = 0; // Reset
1154
- timer.duration = 10; // Change duration
1155
- ```
1156
-
1157
905
  ## Error Handling
1158
906
 
1159
- ECSpresso provides clear, contextual error messages for common issues:
907
+ ECSpresso provides clear, contextual error messages:
1160
908
 
1161
909
  ```typescript
1162
- // Resource not found with helpful context
1163
- try {
1164
- const missing = world.getResource('nonexistent');
1165
- } catch (error) {
1166
- console.error(error.message);
1167
- // "Resource 'nonexistent' not found. Available resources: [config, score, settings]"
1168
- }
910
+ world.getResource('nonexistent');
911
+ // → "Resource 'nonexistent' not found. Available resources: [config, score, settings]"
1169
912
 
1170
- // Entity operations with detailed context
1171
- try {
1172
- world.entityManager.addComponent(999, 'position', { x: 0, y: 0 });
1173
- } catch (error) {
1174
- console.error(error.message);
1175
- // "Cannot add component 'position': Entity with ID 999 does not exist"
1176
- }
913
+ world.entityManager.addComponent(999, 'position', { x: 0, y: 0 });
914
+ // → "Cannot add component 'position': Entity with ID 999 does not exist"
1177
915
 
1178
- // Component not found returns null
1179
- const component = world.entityManager.getComponent(123, 'position');
1180
- if (component === null) {
1181
- console.log('Component not found');
1182
- }
916
+ // Component not found returns null (no throw)
917
+ world.entityManager.getComponent(123, 'position'); // null
1183
918
  ```
1184
919
 
1185
920
  ## Performance Tips
1186
921
 
922
+ - Use `changed` query filters to skip unchanged entities in render sync, transform propagation, and similar systems
923
+ - Call `markChanged` after in-place mutations so downstream systems can detect the change
1187
924
  - Extract business logic into testable helper functions using query type utilities
1188
925
  - Bundle related systems for better organization and reusability
1189
- - Use system priorities to control execution order
926
+ - Use system phases to separate concerns (physics in `fixedUpdate`, rendering in `render`) and priorities for ordering within a phase
1190
927
  - Use resource factories for expensive initialization (textures, audio, etc.)
1191
928
  - Consider component callbacks for immediate reactions to state changes
1192
929
  - Minimize the number of components in queries when possible to leverage indexing