ecspresso 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +18 -1005
  2. package/dist/ecspresso.d.ts +0 -1
  3. package/dist/index.js +4 -0
  4. package/dist/{src/index.js.map → index.js.map} +6 -6
  5. package/dist/{src/plugins → plugins}/renderers/renderer2D.js.map +2 -2
  6. package/dist/screen-manager.d.ts +9 -3
  7. package/package.json +10 -2
  8. package/dist/src/index.js +0 -4
  9. /package/dist/{src/plugins → plugins}/audio.js +0 -0
  10. /package/dist/{src/plugins → plugins}/audio.js.map +0 -0
  11. /package/dist/{src/plugins → plugins}/bounds.js +0 -0
  12. /package/dist/{src/plugins → plugins}/bounds.js.map +0 -0
  13. /package/dist/{src/plugins → plugins}/camera.js +0 -0
  14. /package/dist/{src/plugins → plugins}/camera.js.map +0 -0
  15. /package/dist/{src/plugins → plugins}/collision.js +0 -0
  16. /package/dist/{src/plugins → plugins}/collision.js.map +0 -0
  17. /package/dist/{src/plugins → plugins}/coroutine.js +0 -0
  18. /package/dist/{src/plugins → plugins}/coroutine.js.map +0 -0
  19. /package/dist/{src/plugins → plugins}/diagnostics.js +0 -0
  20. /package/dist/{src/plugins → plugins}/diagnostics.js.map +0 -0
  21. /package/dist/{src/plugins → plugins}/input.js +0 -0
  22. /package/dist/{src/plugins → plugins}/input.js.map +0 -0
  23. /package/dist/{src/plugins → plugins}/particles.js +0 -0
  24. /package/dist/{src/plugins → plugins}/particles.js.map +0 -0
  25. /package/dist/{src/plugins → plugins}/physics2D.js +0 -0
  26. /package/dist/{src/plugins → plugins}/physics2D.js.map +0 -0
  27. /package/dist/{src/plugins → plugins}/renderers/renderer2D.js +0 -0
  28. /package/dist/{src/plugins → plugins}/spatial-index.js +0 -0
  29. /package/dist/{src/plugins → plugins}/spatial-index.js.map +0 -0
  30. /package/dist/{src/plugins → plugins}/sprite-animation.js +0 -0
  31. /package/dist/{src/plugins → plugins}/sprite-animation.js.map +0 -0
  32. /package/dist/{src/plugins → plugins}/state-machine.js +0 -0
  33. /package/dist/{src/plugins → plugins}/state-machine.js.map +0 -0
  34. /package/dist/{src/plugins → plugins}/timers.js +0 -0
  35. /package/dist/{src/plugins → plugins}/timers.js.map +0 -0
  36. /package/dist/{src/plugins → plugins}/transform.js +0 -0
  37. /package/dist/{src/plugins → plugins}/transform.js.map +0 -0
  38. /package/dist/{src/plugins → plugins}/tween.js +0 -0
  39. /package/dist/{src/plugins → plugins}/tween.js.map +0 -0
package/README.md CHANGED
@@ -15,14 +15,10 @@ 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
18
+ - **System Phases**: Named execution phases with fixed-timestep simulation
19
19
  - **Change Detection**: Per-system monotonic sequence change tracking with `changed` query filters
20
20
  - **Reactive Queries**: Enter/exit callbacks when entities match or unmatch queries
21
- - **System Groups**: Enable/disable groups of systems at runtime
22
- - **Component Lifecycle**: Callbacks for component add/remove with unsubscribe support
23
- - **Required Components**: Auto-add dependent components on spawn/addComponent (e.g. `localTransform` implies `worldTransform`)
24
21
  - **Command Buffer**: Deferred structural changes for safe entity/component operations during systems
25
- - **Timer Plugin**: ECS-native timers with event-based completion notifications
26
22
 
27
23
  ## Installation
28
24
 
@@ -30,31 +26,6 @@ A type-safe, modular, and extensible Entity Component System (ECS) framework for
30
26
  npm install ecspresso
31
27
  ```
32
28
 
33
- ## Table of Contents
34
-
35
- - [Quick Start](#quick-start)
36
- - [Core Concepts](#core-concepts)
37
- - [Entities and Components](#entities-and-components) -- [Component Callbacks](#component-callbacks), [Reactive Queries](#reactive-queries)
38
- - [Systems and Queries](#systems-and-queries)
39
- - [Resources](#resources)
40
- - [Systems in Depth](#systems-in-depth)
41
- - [Method Chaining](#method-chaining)
42
- - [Query Type Utilities](#query-type-utilities)
43
- - [System Phases](#system-phases)
44
- - [System Priority](#system-priority)
45
- - [System Groups](#system-groups)
46
- - [System Lifecycle](#system-lifecycle)
47
- - [Events](#events)
48
- - [Entity Hierarchy](#entity-hierarchy) -- [Traversal](#traversal), [Parent-First Traversal](#parent-first-traversal), [Cascade Deletion](#cascade-deletion)
49
- - [Change Detection](#change-detection) -- [Marking Changes](#marking-changes), [Changed Query Filter](#changed-query-filter), [Sequence Timing](#sequence-timing)
50
- - [Command Buffer](#command-buffer) -- [Available Commands](#available-commands)
51
- - [Plugins](#plugins) -- [Plugin Factory](#plugin-factory), [Required Components](#required-components), [Built-in Plugins](#built-in-plugins), [Timer Plugin](#timer-plugin)
52
- - [Asset Management](#asset-management)
53
- - [Screen Management](#screen-management) -- [Screen-Scoped Systems](#screen-scoped-systems), [Screen Resource](#screen-resource)
54
- - [Type Safety](#type-safety)
55
- - [Error Handling](#error-handling)
56
- - [Performance Tips](#performance-tips)
57
-
58
29
  ## Quick Start
59
30
 
60
31
  ```typescript
@@ -93,981 +64,23 @@ const player = world.spawn({
93
64
  world.update(1/60);
94
65
  ```
95
66
 
96
- ## Core Concepts
97
-
98
- ### Entities and Components
99
-
100
- Entities are containers for components. Use `spawn()` to create entities with initial components:
101
-
102
- ```typescript
103
- // Create entity with components
104
- const entity = world.spawn({
105
- position: { x: 10, y: 20 },
106
- health: { value: 100 }
107
- });
108
-
109
- // Add components later
110
- world.entityManager.addComponent(entity.id, 'velocity', { x: 5, y: 0 });
111
-
112
- // Get component data (returns undefined if not found)
113
- const position = world.entityManager.getComponent(entity.id, 'position');
114
-
115
- // Remove components or entities
116
- world.entityManager.removeComponent(entity.id, 'velocity');
117
- world.entityManager.removeEntity(entity.id);
118
- ```
119
-
120
- #### Component Callbacks
121
-
122
- React to component additions and removals. Both methods return an unsubscribe function:
123
-
124
- ```typescript
125
- const unsubAdd = world.onComponentAdded('health', (value, entity) => {
126
- console.log(`Health added to entity ${entity.id}:`, value);
127
- });
128
-
129
- const unsubRemove = world.onComponentRemoved('health', (oldValue, entity) => {
130
- console.log(`Health removed from entity ${entity.id}:`, oldValue);
131
- });
132
-
133
- // Unsubscribe when done
134
- unsubAdd();
135
- unsubRemove();
136
- ```
137
-
138
- Also available on `world.entityManager.onComponentAdded()` / `onComponentRemoved()`.
139
-
140
- #### Reactive Queries
141
-
142
- 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:
143
-
144
- ```typescript
145
- world.addReactiveQuery('enemies', {
146
- with: ['position', 'enemy'],
147
- without: ['dead'],
148
- onEnter: (entity) => {
149
- console.log(`Enemy ${entity.id} appeared at`, entity.components.position);
150
- spawnHealthBar(entity.id);
151
- },
152
- onExit: (entityId) => {
153
- // Receives ID since entity may already be removed
154
- console.log(`Enemy ${entityId} gone`);
155
- removeHealthBar(entityId);
156
- },
157
- });
158
-
159
- // Triggers onEnter: spawning matching entity, adding required component, removing excluded component
160
- const enemy = world.spawn({ position: { x: 0, y: 0 }, enemy: true }); // onEnter fires
161
-
162
- // Triggers onExit: removing required component, adding excluded component, removing entity
163
- world.entityManager.addComponent(enemy.id, 'dead', true); // onExit fires
164
-
165
- // Existing matching entities trigger onEnter when query is added
166
- // Component replacement does NOT trigger enter/exit (match status unchanged)
167
-
168
- // Remove reactive query when no longer needed
169
- world.removeReactiveQuery('enemies'); // returns true if existed
170
- ```
171
-
172
- ### Systems and Queries
173
-
174
- Systems process entities that match specific component patterns:
175
-
176
- ```typescript
177
- world.addSystem('combat')
178
- .addQuery('fighters', {
179
- with: ['position', 'health'],
180
- without: ['dead']
181
- })
182
- .addQuery('projectiles', {
183
- with: ['position', 'damage']
184
- })
185
- .setProcess((queries, deltaTime) => {
186
- for (const fighter of queries.fighters) {
187
- for (const projectile of queries.projectiles) {
188
- // Combat logic here
189
- }
190
- }
191
- });
192
- ```
193
-
194
- ### Resources
195
-
196
- Resources provide global state accessible to all systems.
197
-
198
- ```typescript
199
- interface Resources {
200
- score: { value: number };
201
- settings: { difficulty: 'easy' | 'hard' };
202
- }
203
-
204
- const world = ECSpresso.create()
205
- .withComponentTypes<Components>()
206
- .withResourceTypes<Resources>()
207
- .withResource('score', { value: 0 })
208
- .build();
209
-
210
- // Sync or async factories (lazy initialization)
211
- world.addResource('config', () => ({ difficulty: 'normal', soundEnabled: true }));
212
- world.addResource('database', async () => await connectToDatabase());
213
-
214
- // Factory with dependencies (initialized after dependencies are ready)
215
- world.addResource('cache', {
216
- dependsOn: ['database'],
217
- factory: (ecs) => ({ db: ecs.getResource('database') })
218
- });
219
-
220
- // Initialize all resources (respects dependency order, detects circular deps)
221
- await world.initializeResources();
222
-
223
- // Use in systems
224
- world.addSystem('scoring')
225
- .setProcess((queries, deltaTime, ecs) => {
226
- const score = ecs.getResource('score');
227
- score.value += 10;
228
- });
229
- ```
230
-
231
- Resources also chain naturally with plugins in the builder:
232
-
233
- ```typescript
234
- const world = ECSpresso.create()
235
- .withPlugin(physicsPlugin)
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
266
-
267
- ### Method Chaining
268
-
269
- Systems use a fluent builder API: `world.addSystem().addQuery().setProcess()` -- systems are automatically registered via deferred finalization. No explicit termination call is needed.
270
-
271
- ```typescript
272
- world.addSystem('physics')
273
- .addQuery('moving', { with: ['position', 'velocity'] })
274
- .setProcess((queries, deltaTime) => {
275
- // Physics logic
276
- });
277
-
278
- world.addSystem('rendering')
279
- .addQuery('visible', { with: ['position', 'sprite'] })
280
- .setProcess((queries) => {
281
- // Rendering logic
282
- });
283
- ```
284
-
285
- ### Query Type Utilities
286
-
287
- Extract entity types from queries to create reusable helper functions:
288
-
289
- ```typescript
290
- import { createQueryDefinition, QueryResultEntity } from 'ecspresso';
291
-
292
- // Create reusable query definitions
293
- const movingQuery = createQueryDefinition<Components>({
294
- with: ['position', 'velocity'],
295
- without: ['frozen']
296
- });
297
-
298
- // Extract entity type for helper functions
299
- type MovingEntity = QueryResultEntity<Components, typeof movingQuery>;
300
-
301
- function updatePosition(entity: MovingEntity, deltaTime: number) {
302
- entity.components.position.x += entity.components.velocity.x * deltaTime;
303
- entity.components.position.y += entity.components.velocity.y * deltaTime;
304
- }
305
-
306
- // Use in systems
307
- world.addSystem('movement')
308
- .addQuery('entities', movingQuery)
309
- .setProcess((queries, deltaTime) => {
310
- for (const entity of queries.entities) {
311
- updatePosition(entity, deltaTime);
312
- }
313
- });
314
- ```
315
-
316
- ### System Phases
317
-
318
- Systems are organized into named execution phases that run in a fixed order:
319
-
320
- ```
321
- preUpdate → fixedUpdate → update → postUpdate → render
322
- ```
323
-
324
- 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`.
325
-
326
- ```typescript
327
- world.addSystem('input')
328
- .inPhase('preUpdate')
329
- .setProcess((queries, dt, ecs) => { /* Read input, update timers */ });
330
-
331
- world.addSystem('physics')
332
- .inPhase('fixedUpdate')
333
- .setProcess((queries, dt, ecs) => {
334
- // dt is always fixedDt here (e.g. 1/60)
335
- // Runs 0..N times per frame based on accumulated time
336
- });
337
-
338
- world.addSystem('gameplay')
339
- .inPhase('update') // default phase
340
- .setProcess((queries, dt, ecs) => { /* Game logic, AI */ });
341
-
342
- world.addSystem('transform-sync')
343
- .inPhase('postUpdate')
344
- .setProcess((queries, dt, ecs) => { /* Transform propagation */ });
345
-
346
- world.addSystem('renderer')
347
- .inPhase('render')
348
- .setProcess((queries, dt, ecs) => { /* Visual output */ });
349
- ```
350
-
351
- **Fixed Timestep** -- The `fixedUpdate` phase uses a time accumulator for deterministic simulation. A spiral-of-death cap (8 steps) prevents runaway accumulation.
352
-
353
- ```typescript
354
- const world = ECSpresso.create()
355
- .withComponentTypes<Components>()
356
- .withEventTypes<Events>()
357
- .withResourceTypes<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
-
366
- ### System Priority
367
-
368
- Within each phase, systems execute in priority order (higher numbers first). Systems with the same priority execute in registration order:
369
-
370
- ```typescript
371
- world.addSystem('physics')
372
- .inPhase('fixedUpdate')
373
- .setPriority(100) // Runs first within fixedUpdate
374
- .setProcess(() => { /* physics */ });
375
-
376
- world.addSystem('constraints')
377
- .inPhase('fixedUpdate')
378
- .setPriority(50) // Runs second within fixedUpdate
379
- .setProcess(() => { /* constraints */ });
380
- ```
381
-
382
- ### System Groups
383
-
384
- Organize systems into groups that can be enabled/disabled at runtime:
385
-
386
- ```typescript
387
- world.addSystem('renderSprites')
388
- .inGroup('rendering')
389
- .addQuery('sprites', { with: ['position', 'sprite'] })
390
- .setProcess((queries) => { /* ... */ });
391
-
392
- world.addSystem('renderParticles')
393
- .inGroup('rendering')
394
- .inGroup('effects') // Systems can belong to multiple groups
395
- .setProcess(() => { /* ... */ });
396
-
397
- world.disableSystemGroup('rendering'); // All rendering systems skip
398
- world.enableSystemGroup('rendering'); // Resume rendering
399
- world.isSystemGroupEnabled('rendering'); // true/false
400
- world.getSystemsInGroup('rendering'); // ['renderSprites', 'renderParticles']
401
-
402
- // If a system belongs to multiple groups, disabling ANY group skips the system
403
- ```
404
-
405
- ### System Lifecycle
406
-
407
- Systems can have initialization, cleanup, and post-update hooks:
408
-
409
- ```typescript
410
- world.addSystem('gameSystem')
411
- .setOnInitialize(async (ecs) => {
412
- console.log('System starting...');
413
- })
414
- .setOnDetach((ecs) => {
415
- console.log('System shutting down...');
416
- });
417
-
418
- await world.initialize();
419
- ```
420
-
421
- **Post-Update Hooks** -- Register callbacks that run between the `postUpdate` and `render` phases:
422
-
423
- ```typescript
424
- // Returns unsubscribe function; multiple hooks run in registration order
425
- const unsubscribe = world.onPostUpdate((ecs, deltaTime) => {
426
- console.log(`Frame completed in ${deltaTime}s`);
427
- });
428
-
429
- unsubscribe();
430
- ```
431
-
432
- ## Events
433
-
434
- 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.
435
-
436
- ```typescript
437
- interface Events {
438
- playerDied: { playerId: number };
439
- levelComplete: { score: number };
440
- // Hierarchy events (if using entity hierarchy)
441
- hierarchyChanged: {
442
- entityId: number;
443
- oldParent: number | null;
444
- newParent: number | null;
445
- };
446
- }
447
-
448
- const world = ECSpresso.create()
449
- .withComponentTypes<Components>()
450
- .withEventTypes<Events>()
451
- .build();
452
-
453
- // Subscribe - returns unsubscribe function
454
- const unsubscribe = world.on('playerDied', (data) => {
455
- console.log(`Player ${data.playerId} died`);
456
- });
457
- unsubscribe();
458
-
459
- // Or unsubscribe by callback reference
460
- const handler = (data) => console.log(`Score: ${data.score}`);
461
- world.on('levelComplete', handler);
462
- world.off('levelComplete', handler);
463
-
464
- // Handle events in systems
465
- world.addSystem('gameLogic')
466
- .setEventHandlers({
467
- playerDied: (data, ecs) => {
468
- // Respawn logic
469
- }
470
- });
471
-
472
- // Publish events from anywhere
473
- world.eventBus.publish('playerDied', { playerId: 1 });
474
- ```
475
-
476
- **Built-in events**: `hierarchyChanged` (entity parent changes), `assetLoaded` / `assetFailed` / `assetGroupProgress` / `assetGroupLoaded` (asset loading), and timer `onComplete` events (see [Plugins](#plugins)).
477
-
478
- ## Entity Hierarchy
479
-
480
- Create parent-child relationships between entities for scene graphs, UI trees, or skeletal hierarchies:
481
-
482
- ```typescript
483
- const player = world.spawn({ position: { x: 0, y: 0 } });
484
-
485
- // Create child entity
486
- const weapon = world.spawnChild(player.id, { position: { x: 10, y: 0 } });
487
-
488
- // Or set parent on existing entity
489
- const shield = world.spawn({ position: { x: -10, y: 0 } });
490
- world.setParent(shield.id, player.id);
491
-
492
- // Orphan an entity
493
- world.removeParent(shield.id);
494
- ```
495
-
496
- ### Traversal
497
-
498
- | Method | Returns | Description |
499
- |--------|---------|-------------|
500
- | `getParent(id)` | `number \| null` | Parent entity ID |
501
- | `getChildren(id)` | `number[]` | Direct children |
502
- | `getAncestors(id)` | `number[]` | Entity up to root |
503
- | `getDescendants(id)` | `number[]` | Depth-first order |
504
- | `getRoot(id)` | `number` | Root of the hierarchy |
505
- | `getSiblings(id)` | `number[]` | Other children of same parent |
506
- | `getRootEntities()` | `number[]` | All root entities |
507
- | `getChildAt(id, index)` | `number` | Child at index |
508
- | `getChildIndex(parentId, childId)` | `number` | Index of child |
509
- | `isDescendantOf(id, ancestorId)` | `boolean` | Relationship check |
510
- | `isAncestorOf(id, descendantId)` | `boolean` | Relationship check |
511
-
512
- ### Parent-First Traversal
513
-
514
- Iterate the hierarchy with guaranteed parent-first order (useful for transform propagation):
515
-
516
- ```typescript
517
- // Callback-based traversal
518
- world.forEachInHierarchy((entityId, parentId, depth) => {
519
- // Parents are always visited before their children
520
- });
521
-
522
- // Filter to specific subtrees
523
- world.forEachInHierarchy(callback, { roots: [root.id] });
524
-
525
- // Generator-based (supports early termination)
526
- for (const { entityId, parentId, depth } of world.hierarchyIterator()) {
527
- if (depth > 2) break;
528
- }
529
- ```
530
-
531
- ### Cascade Deletion
532
-
533
- When removing entities, descendants are automatically removed by default:
534
-
535
- ```typescript
536
- world.removeEntity(parent.id);
537
- // All descendants are removed
538
-
539
- // To orphan children instead:
540
- world.removeEntity(parent.id, { cascade: false });
541
- ```
67
+ ## Documentation
542
68
 
543
- Hierarchy changes emit the `hierarchyChanged` event (see [Events](#events)).
544
-
545
- **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 plugin implements this automatically.
546
-
547
- ## Change Detection
548
-
549
- 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.
550
-
551
- ### Marking Changes
552
-
553
- Components are automatically marked as changed when added via `spawn()`, `addComponent()`, or `addComponents()`. For in-place mutations, call `markChanged` explicitly:
554
-
555
- ```typescript
556
- const position = world.entityManager.getComponent(entity.id, 'position');
557
- if (position) {
558
- position.x += 10;
559
- world.markChanged(entity.id, 'position');
560
- }
561
- ```
562
-
563
- ### Changed Query Filter
564
-
565
- Add `changed` to a query definition to filter entities to only those whose specified components changed since the system last ran:
566
-
567
- ```typescript
568
- world.addSystem('render-sync')
569
- .addQuery('moved', {
570
- with: ['position', 'sprite'],
571
- changed: ['position'], // Only entities whose position changed this tick
572
- })
573
- .setProcess((queries) => {
574
- for (const entity of queries.moved) {
575
- syncSpritePosition(entity);
576
- }
577
- });
578
- ```
579
-
580
- When multiple components are listed in `changed`, entities matching **any** of them are included (OR semantics).
581
-
582
- ### Sequence Timing
583
-
584
- - Marks made between updates are visible to all systems on the next update
585
- - Spawn auto-marks are visible on the first update
586
- - Marks from earlier phases are visible to later phases within the same frame
587
- - Within a phase, a higher-priority system's marks are visible to lower-priority systems
588
- - Each mark is processed exactly once per system (single-update expiry)
589
-
590
- For manual change detection outside of system queries:
591
-
592
- ```typescript
593
- const em = ecs.entityManager;
594
- if (em.getChangeSeq(entity.id, 'localTransform') > ecs.changeThreshold) {
595
- // Component changed since last system execution (or since last update if between updates)
596
- }
597
- ```
598
-
599
- **Deferred marking**: `ecs.commands.markChanged(entity.id, 'position')` queues a mark for command buffer playback.
600
-
601
- **Built-in plugin usage**: Movement marks `localTransform` (fixedUpdate) → Transform propagation reads `localTransform` changed, writes+marks `worldTransform` (postUpdate) → Renderer reads `worldTransform` changed (render).
602
-
603
- ## Command Buffer
604
-
605
- Queue structural changes during system execution that execute between phases. This prevents issues when modifying entities during iteration.
606
-
607
- ```typescript
608
- world.addSystem('combat')
609
- .addQuery('enemies', { with: ['enemy', 'health'] })
610
- .setProcess((queries, dt, ecs) => {
611
- for (const entity of queries.enemies) {
612
- if (entity.components.health.value <= 0) {
613
- ecs.commands.removeEntity(entity.id);
614
- ecs.commands.spawn({
615
- position: entity.components.position,
616
- explosion: true,
617
- });
618
- }
619
- }
620
- });
621
- ```
622
-
623
- ### Available Commands
624
-
625
- ```typescript
626
- // Entity operations
627
- ecs.commands.spawn({ position: { x: 0, y: 0 } });
628
- ecs.commands.spawnChild(parentId, { position: { x: 10, y: 0 } });
629
- ecs.commands.removeEntity(entityId);
630
- ecs.commands.removeEntity(entityId, { cascade: false });
631
-
632
- // Component operations
633
- ecs.commands.addComponent(entityId, 'velocity', { x: 5, y: 0 });
634
- ecs.commands.addComponents(entityId, { velocity: { x: 5, y: 0 }, health: { value: 100 } });
635
- ecs.commands.removeComponent(entityId, 'velocity');
636
-
637
- // Hierarchy operations
638
- ecs.commands.setParent(childId, parentId);
639
- ecs.commands.removeParent(childId);
640
-
641
- // Change detection
642
- ecs.commands.markChanged(entityId, 'position');
643
-
644
- // Utility
645
- ecs.commands.length; // Number of queued commands
646
- ecs.commands.clear(); // Discard all queued commands
647
- ```
648
-
649
- Commands execute in FIFO order. If a command fails (e.g., entity doesn't exist), it logs a warning and continues with remaining commands.
650
-
651
- ## Plugins
652
-
653
- Organize related systems and resources into reusable plugins:
654
-
655
- ```typescript
656
- import ECSpresso, { definePlugin, type WorldConfigFrom } from 'ecspresso';
657
-
658
- interface PhysicsComponents {
659
- position: { x: number; y: number };
660
- velocity: { x: number; y: number };
661
- }
662
-
663
- interface PhysicsResources {
664
- gravity: { value: number };
665
- }
666
-
667
- const physicsPlugin = definePlugin<WorldConfigFrom<PhysicsComponents, {}, PhysicsResources>>({
668
- id: 'physics',
669
- install(world) {
670
- world.addSystem('applyVelocity')
671
- .addQuery('moving', { with: ['position', 'velocity'] })
672
- .setProcess((queries, deltaTime) => {
673
- for (const entity of queries.moving) {
674
- entity.components.position.x += entity.components.velocity.x * deltaTime;
675
- entity.components.position.y += entity.components.velocity.y * deltaTime;
676
- }
677
- });
678
-
679
- world.addSystem('applyGravity')
680
- .addQuery('falling', { with: ['velocity'] })
681
- .setProcess((queries, deltaTime, ecs) => {
682
- const gravity = ecs.getResource('gravity');
683
- for (const entity of queries.falling) {
684
- entity.components.velocity.y += gravity.value * deltaTime;
685
- }
686
- });
687
-
688
- world.addResource('gravity', { value: 9.8 });
689
- },
690
- });
691
-
692
- // Register plugins with the world — types merge automatically
693
- const game = ECSpresso.create()
694
- .withPlugin(physicsPlugin)
695
- .build();
696
- ```
697
-
698
- ### Plugin Factory
699
-
700
- When multiple plugins share the same types (common in application code), use `pluginFactory()` on the builder or built world to capture types automatically:
701
-
702
- ```typescript
703
- // types.ts — builder accumulates all types
704
- export const builder = ECSpresso.create()
705
- .withPlugin(createPhysicsPlugin())
706
- .withComponentTypes<{ player: boolean; enemy: EnemyData }>()
707
- .withResourceTypes<{ score: number }>();
708
-
709
- // Types flow from the builder — no manual imports or extends chains
710
- export const definePlugin = builder.pluginFactory();
711
-
712
- // movement-plugin.ts — no type params needed
713
- import { definePlugin } from './types';
714
-
715
- export const movementPlugin = definePlugin({
716
- id: 'movement',
717
- install(world) {
718
- world.addSystem('movement')
719
- .addQuery('moving', { with: ['position', 'velocity'] })
720
- .setProcess((queries, dt) => { /* ... */ });
721
- },
722
- });
723
- ```
724
-
725
- You can also pass a world type directly to `definePlugin` as a one-off alternative:
726
-
727
- ```typescript
728
- type MyWorld = typeof ecs; // derive from a built world
729
- const plugin = definePlugin<MyWorld>({
730
- id: 'my-plugin',
731
- install(world) { /* world is fully typed */ },
732
- });
733
- ```
734
-
735
- ### Required Components
736
-
737
- Plugins can declare that certain components depend on others. When an entity gains a trigger component, any required components that aren't already present are auto-added with default values:
738
-
739
- ```typescript
740
- const transformPlugin = definePlugin<WorldConfigFrom<TransformComponents>>({
741
- id: 'transform',
742
- install(world) {
743
- world.registerRequired('localTransform', 'worldTransform', () => ({
744
- x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1,
745
- }));
746
- },
747
- });
748
-
749
- const world = ECSpresso.create()
750
- .withPlugin(transformPlugin)
751
- .build();
752
-
753
- // worldTransform is auto-added with defaults
754
- const entity = world.spawn({
755
- localTransform: { x: 100, y: 200, rotation: 0, scaleX: 1, scaleY: 1 },
756
- });
757
-
758
- // Explicit values always win — no auto-add if already provided
759
- const entity2 = world.spawn({
760
- localTransform: { x: 100, y: 200, rotation: 0, scaleX: 1, scaleY: 1 },
761
- worldTransform: { x: 50, y: 50, rotation: 0, scaleX: 2, scaleY: 2 }, // used as-is
762
- });
763
- ```
764
-
765
- Requirements can also be registered via the builder or at runtime:
766
-
767
- ```typescript
768
- // Builder
769
- const world = ECSpresso.create()
770
- .withComponentTypes<Components>()
771
- .withRequired('rigidBody', 'velocity', () => ({ x: 0, y: 0 }))
772
- .withRequired('rigidBody', 'force', () => ({ x: 0, y: 0 }))
773
- .build();
774
-
775
- // Runtime
776
- world.registerRequired('position', 'velocity', () => ({ x: 0, y: 0 }));
777
- ```
778
-
779
- **Behavior:**
780
- - Enforced at insertion time (`spawn`, `addComponent`, `addComponents`, `spawnChild`, command buffer)
781
- - Removal is unrestricted — removing a required component does not cascade
782
- - Transitive requirements resolve automatically (A requires B, B requires C → all three added)
783
- - Circular dependencies are detected and rejected at registration time
784
- - Auto-added components are marked as changed and trigger reactive queries
785
- - Component names and factory return types are fully type-checked
786
-
787
- **Built-in requirements:** The Transform plugin registers `localTransform` → `worldTransform`. The Physics 2D plugin registers `rigidBody` → `velocity` and `rigidBody` → `force`.
788
-
789
- ### Built-in Plugins
790
-
791
- | Plugin | Import | Default Phase | Description |
792
- |--------|--------|---------------|-------------|
793
- | **Input** | `ecspresso/plugins/input` | `preUpdate` | Frame-accurate keyboard/pointer input with action mapping |
794
- | **Timers** | `ecspresso/plugins/timers` | `preUpdate` | ECS-native timers with event-based completion |
795
- | **Movement** | `ecspresso/plugins/movement` | `fixedUpdate` | Velocity-based movement integration |
796
- | **Transform** | `ecspresso/plugins/transform` | `postUpdate` | Hierarchical transform propagation (local/world transforms) |
797
- | **Bounds** | `ecspresso/plugins/bounds` | `postUpdate` | Screen bounds enforcement (destroy, clamp, wrap) |
798
- | **Collision** | `ecspresso/plugins/collision` | `postUpdate` | Layer-based AABB/circle collision detection with events |
799
- | **2D Renderer** | `ecspresso/plugins/renderers/renderer2D` | `render` | Automated PixiJS scene graph wiring |
800
-
801
- Each plugin accepts a `phase` option to override its default.
802
-
803
- ### Input Plugin
804
-
805
- The input plugin provides frame-accurate keyboard, pointer (mouse + touch via PointerEvent), and named action mapping. It's a resource-only plugin — input is polled via the `inputState` resource. DOM events are accumulated between frames and snapshotted once per frame, so all systems see consistent state.
806
-
807
- ```typescript
808
- import {
809
- createInputPlugin,
810
- type InputResourceTypes, type KeyCode
811
- } from 'ecspresso/plugins/input';
812
-
813
- const world = ECSpresso.create()
814
- .withPlugin(createInputPlugin({
815
- actions: {
816
- jump: { keys: [' ', 'ArrowUp'] },
817
- shoot: { keys: ['z'], buttons: [0] },
818
- moveLeft: { keys: ['a', 'ArrowLeft'] },
819
- moveRight: { keys: ['d', 'ArrowRight'] },
820
- },
821
- }))
822
- .build();
823
-
824
- // In a system:
825
- const input = ecs.getResource('inputState');
826
- if (input.actions.justActivated('jump')) { /* ... */ }
827
- if (input.keyboard.isDown('ArrowRight')) { /* ... */ }
828
- if (input.pointer.justPressed(0)) { /* ... */ }
829
-
830
- // Runtime remapping — must include all configured actions
831
- input.setActionMap({
832
- jump: { keys: ['w'] },
833
- shoot: { keys: ['z'], buttons: [0] },
834
- moveLeft: { keys: ['a'] },
835
- moveRight: { keys: ['d'] },
836
- });
837
- ```
838
-
839
- Action names are type-safe — `isActive`, `justActivated`, `justDeactivated`, `setActionMap`, and `getActionMap` only accept action names from the config. The type parameter `A` is inferred from the `actions` object keys passed to `createInputPlugin`. Defaults to `string` when no actions are configured.
840
-
841
- Key values use the `KeyCode` type — a union of all standard `KeyboardEvent.key` values — providing autocomplete and compile-time validation. Note that the space bar key is `' '` (a space character), not `'Space'`.
842
-
843
- ### Timer Plugin
844
-
845
- The timer plugin provides ECS-native timers that follow the "data, not callbacks" philosophy. Timers are components processed each frame, with optional event-based completion notifications.
846
-
847
- ```typescript
848
- import {
849
- createTimerPlugin, createTimer, createRepeatingTimer,
850
- type TimerComponentTypes, type TimerEventData
851
- } from 'ecspresso/plugins/timers';
852
-
853
- // Events used with onComplete must have TimerEventData payload
854
- interface Events {
855
- hideMessage: TimerEventData; // { entityId, duration, elapsed }
856
- spawnWave: TimerEventData;
857
- }
858
-
859
- const world = ECSpresso
860
- .create()
861
- .withPlugin(createTimerPlugin())
862
- .withComponentTypes<{ position: { x: number; y: number } }>()
863
- .withEventTypes<Events>()
864
- .build();
865
-
866
- // One-shot timer (poll justFinished or use onComplete event)
867
- world.spawn({ ...createTimer(2.0), position: { x: 0, y: 0 } });
868
- world.spawn({ ...createTimer(1.5, { onComplete: 'hideMessage' }) });
869
-
870
- // Repeating timer
871
- world.spawn({ ...createRepeatingTimer(5.0, { onComplete: 'spawnWave' }) });
872
- ```
873
-
874
- Timer components expose `elapsed`, `duration`, `repeat`, `active`, `justFinished`, and optional `onComplete` for runtime control.
875
-
876
- ## Asset Management
877
-
878
- Manage game assets with eager/lazy loading, groups, and progress tracking:
879
-
880
- ```typescript
881
- type Assets = {
882
- playerTexture: { data: ImageBitmap };
883
- level1Music: { buffer: AudioBuffer };
884
- level1Background: { data: ImageBitmap };
885
- };
886
-
887
- const game = ECSpresso.create()
888
- .withComponentTypes<Components>()
889
- .withEventTypes<Events>()
890
- .withResourceTypes<Resources>()
891
- .withAssets(assets => assets
892
- // Eager assets - loaded automatically during initialize()
893
- .add('playerTexture', async () => {
894
- const img = await loadImage('player.png');
895
- return { data: img };
896
- })
897
- // Lazy asset group - loaded on demand
898
- .addGroup('level1', {
899
- level1Music: async () => ({ buffer: await loadAudio('level1.mp3') }),
900
- level1Background: async () => ({ data: await loadImage('level1-bg.png') }),
901
- })
902
- )
903
- .build();
904
-
905
- await game.initialize(); // Loads eager assets
906
- const player = game.getAsset('playerTexture'); // Access loaded asset
907
- game.isAssetLoaded('playerTexture'); // Check if loaded
908
-
909
- await game.loadAssetGroup('level1'); // Load group on demand
910
- game.getAssetGroupProgress('level1'); // 0-1 progress
911
- game.isAssetGroupLoaded('level1'); // Check if group is ready
912
- ```
913
-
914
- Systems can declare required assets and will only run when those assets are loaded:
915
-
916
- ```typescript
917
- game.addSystem('gameplay')
918
- .requiresAssets(['playerTexture'])
919
- .setProcess((queries, dt, ecs) => {
920
- const player = ecs.getAsset('playerTexture');
921
- });
922
- ```
923
-
924
- Asset events (`assetLoaded`, `assetFailed`, `assetGroupProgress`, `assetGroupLoaded`) are available through the event system -- see [Events](#events).
925
-
926
- ## Screen Management
927
-
928
- Manage game states/screens with transitions and overlay support:
929
-
930
- ```typescript
931
- import type { ScreenDefinition } from 'ecspresso';
932
-
933
- type Screens = {
934
- menu: ScreenDefinition<
935
- Record<string, never>, // Config (passed when entering)
936
- { selectedOption: number } // State (mutable during screen)
937
- >;
938
- gameplay: ScreenDefinition<
939
- { difficulty: string; level: number },
940
- { score: number; isPaused: boolean }
941
- >;
942
- pause: ScreenDefinition<Record<string, never>, Record<string, never>>;
943
- };
944
-
945
- const game = ECSpresso.create()
946
- .withComponentTypes<Components>()
947
- .withEventTypes<Events>()
948
- .withResourceTypes<Resources>()
949
- .withScreens(screens => screens
950
- .add('menu', {
951
- initialState: () => ({ selectedOption: 0 }),
952
- onEnter: () => console.log('Entered menu'),
953
- onExit: () => console.log('Left menu'),
954
- })
955
- .add('gameplay', {
956
- initialState: () => ({ score: 0, isPaused: false }),
957
- onEnter: (config) => console.log(`Starting level ${config.level}`),
958
- onExit: () => console.log('Gameplay ended'),
959
- requiredAssetGroups: ['level1'],
960
- })
961
- .add('pause', {
962
- initialState: () => ({}),
963
- })
964
- )
965
- .build();
966
-
967
- await game.initialize();
968
- await game.setScreen('menu', {}); // Set initial screen
969
- await game.setScreen('gameplay', { difficulty: 'hard', level: 1 }); // Transition
970
- await game.pushScreen('pause', {}); // Push overlay
971
- await game.popScreen(); // Pop overlay
972
-
973
- const current = game.getCurrentScreen(); // 'gameplay'
974
- const config = game.getScreenConfig(); // { difficulty: 'hard', level: 1 }
975
- const state = game.getScreenState(); // { score: 0, isPaused: false }
976
- game.updateScreenState({ score: 100 });
977
- ```
978
-
979
- ### Screen-Scoped Systems
980
-
981
- ```typescript
982
- game.addSystem('menuUI')
983
- .inScreens(['menu']) // Only runs in 'menu'
984
- .setProcess((queries, dt, ecs) => {
985
- renderMenu(ecs.getScreenState().selectedOption);
986
- });
987
-
988
- game.addSystem('animations')
989
- .excludeScreens(['pause']) // Runs in all screens except 'pause'
990
- .setProcess(() => { /* ... */ });
991
- ```
992
-
993
- ### Screen Resource
994
-
995
- Access screen state through the `$screen` resource:
996
-
997
- ```typescript
998
- game.addSystem('ui')
999
- .setProcess((queries, dt, ecs) => {
1000
- const screen = ecs.getResource('$screen');
1001
- screen.current; // Current screen name
1002
- screen.config; // Current screen config
1003
- screen.state; // Current screen state (mutable)
1004
- screen.isOverlay; // true if screen was pushed
1005
- screen.stackDepth; // Number of screens in stack
1006
- screen.isCurrent('gameplay'); // Check current screen
1007
- screen.isActive('menu'); // true if in current or stack
1008
- });
1009
- ```
1010
-
1011
- ## Type Safety
1012
-
1013
- ECSpresso provides comprehensive TypeScript support:
1014
-
1015
- ```typescript
1016
- // ✅ Valid
1017
- world.entityManager.addComponent(entity.id, 'position', { x: 0, y: 0 });
1018
-
1019
- // ❌ TypeScript error - invalid component name
1020
- world.entityManager.addComponent(entity.id, 'invalid', { data: 'bad' });
1021
-
1022
- // ❌ TypeScript error - wrong component shape
1023
- world.entityManager.addComponent(entity.id, 'position', { x: 0 }); // missing y
1024
-
1025
- // Query type safety - TypeScript knows which components exist
1026
- world.addSystem('example')
1027
- .addQuery('moving', { with: ['position', 'velocity'] })
1028
- .setProcess((queries) => {
1029
- for (const entity of queries.moving) {
1030
- entity.components.position.x; // ✅ guaranteed
1031
- entity.components.health.value; // ❌ not in query
1032
- }
1033
- });
1034
-
1035
- // Plugin type compatibility - conflicting types error at compile time
1036
- const plugin1 = definePlugin<WorldConfigFrom<{ position: { x: number; y: number } }>>({
1037
- id: 'p1', install() {},
1038
- });
1039
- const plugin2 = definePlugin<WorldConfigFrom<{ velocity: { x: number; y: number } }>>({
1040
- id: 'p2', install() {},
1041
- });
1042
- // Builder merges plugin types automatically — no manual type params needed
1043
- const world = ECSpresso.create()
1044
- .withPlugin(plugin1)
1045
- .withPlugin(plugin2)
1046
- .build();
1047
- ```
1048
-
1049
- ## Error Handling
1050
-
1051
- ECSpresso provides clear, contextual error messages:
1052
-
1053
- ```typescript
1054
- world.getResource('nonexistent');
1055
- // → "Resource 'nonexistent' not found. Available resources: [config, score, settings]"
1056
-
1057
- world.entityManager.addComponent(999, 'position', { x: 0, y: 0 });
1058
- // → "Cannot add component 'position': Entity with ID 999 does not exist"
1059
-
1060
- // Component not found returns undefined (no throw)
1061
- world.entityManager.getComponent(123, 'position'); // undefined
1062
- ```
69
+ - [Getting Started](./docs/getting-started.md)
70
+ - [Core Concepts](./docs/core-concepts.md) — entities, components, systems, resources
71
+ - [Systems](./docs/systems.md) phases, priority, groups, lifecycle
72
+ - [Queries](./docs/queries.md) — type utilities, reactive queries
73
+ - [Events](./docs/events.md) — pub/sub, built-in events
74
+ - [Entity Hierarchy](./docs/hierarchy.md) — parent-child, traversal, cascade deletion
75
+ - [Change Detection](./docs/change-detection.md) marking, sequence timing
76
+ - [Command Buffer](./docs/command-buffer.md) — deferred structural changes
77
+ - [Plugins](./docs/plugins.md) — definePlugin, pluginFactory, required components
78
+ - [Asset Management](./docs/assets.md) — loading, groups, progress
79
+ - [Screen Management](./docs/screens.md) transitions, scoped systems, overlays
80
+ - [Built-in Plugins](./docs/built-in-plugins.md) — input, timers, physics, rendering
81
+ - [Type Safety](./docs/type-safety.md) — type threading, error handling
82
+ - [Performance](./docs/performance.md) — optimization tips
1063
83
 
1064
- ## Performance Tips
84
+ ## License
1065
85
 
1066
- - Use `changed` query filters to skip unchanged entities in render sync, transform propagation, and similar systems
1067
- - Call `markChanged` after in-place mutations so downstream systems can detect the change
1068
- - Extract business logic into testable helper functions using query type utilities
1069
- - Group related systems into plugins for better organization and reusability
1070
- - Use system phases to separate concerns (physics in `fixedUpdate`, rendering in `render`) and priorities for ordering within a phase
1071
- - Use resource factories for expensive initialization (textures, audio, etc.)
1072
- - Consider component callbacks for immediate reactions to state changes
1073
- - Minimize the number of components in queries when possible to leverage indexing
86
+ MIT