@daneren2005/shared-memory-ecs 1.5.1 → 1.7.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.
Files changed (32) hide show
  1. package/README.md +222 -44
  2. package/dist/actions/add-component-worker.d.ts +3 -0
  3. package/dist/actions/build-worker-entity.d.ts +4 -2
  4. package/dist/actions/create-entity-worker.d.ts +2 -2
  5. package/dist/actions/kill-entity-worker.d.ts +2 -2
  6. package/dist/actions/remove-component-worker.d.ts +3 -0
  7. package/dist/component-definition.d.ts +2 -0
  8. package/dist/create-system-worker-DURlIMia.js +345 -0
  9. package/dist/create-system-worker-DURlIMia.js.map +1 -0
  10. package/dist/entity-class.d.ts +51 -0
  11. package/dist/entity-component.d.ts +7 -2
  12. package/dist/entity-factory.d.ts +21 -5
  13. package/dist/entity.d.ts +11 -2
  14. package/dist/index.d.ts +15 -8
  15. package/dist/index.js +379 -166
  16. package/dist/index.js.map +1 -1
  17. package/dist/systems/{component-system.d.ts → entity-worker-system.d.ts} +39 -18
  18. package/dist/systems/system.d.ts +1 -1
  19. package/dist/systems/worker-system.d.ts +22 -0
  20. package/dist/systems/workers/apply-query-delta.d.ts +1 -1
  21. package/dist/systems/workers/create-entity-system-worker.d.ts +8 -0
  22. package/dist/systems/workers/create-system-worker.d.ts +9 -0
  23. package/dist/systems/workers/entity-system-web-worker.d.ts +18 -0
  24. package/dist/systems/workers/{component-worker-message.d.ts → entity-system-worker-message.d.ts} +8 -4
  25. package/dist/worker.d.ts +10 -6
  26. package/dist/worker.js +2 -2
  27. package/dist/world.d.ts +17 -17
  28. package/package.json +2 -1
  29. package/dist/create-component-worker-BM942gjg.js +0 -288
  30. package/dist/create-component-worker-BM942gjg.js.map +0 -1
  31. package/dist/systems/workers/component-web-worker.d.ts +0 -17
  32. package/dist/systems/workers/create-component-worker.d.ts +0 -8
package/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  A small, reusable Entity / Component / System core backed by shared memory
4
4
  ([`@daneren2005/shared-memory-objects`](https://www.npmjs.com/package/@daneren2005/shared-memory-objects)).
5
- It contains only the engine-agnostic parts of the ECS - no game specific concepts (factions, terrain,
6
- fog of war, sub-classed entities, required components, etc).
5
+ It contains only engine-agnostic ECS concepts. Games supply their own components, entity classes, templates,
6
+ systems, and domain data.
7
7
 
8
8
  ## Concepts
9
9
 
@@ -14,24 +14,29 @@ fog of war, sub-classed entities, required components, etc).
14
14
  config to the raw block values) and `attach(entity, memory, index)` (builds the accessor over that block).
15
15
  Loading a component is `attach(entity, memory, memory.create(toBlock(config)))`; splitting it this way lets a
16
16
  worker build the block off-thread (it calls only `toBlock`) and the main thread wrap it (`attach`) when it
17
- adopts the entity. Plus an optional `save(component)` and `free(component)`. A component's data splits into
18
- `Config` (defining props supplied up front, e.g. `maxHealth`) and `Serialization` (runtime-derived state,
19
- e.g. current `health`); `toBlock` sees `Config & Serialization` while `save` returns only the `Serialization`
20
- slice. `free` runs when the component is torn down (see [Freeing extra resources](#freeing-extra-resources)).
17
+ adopts the entity. Plus an optional `save(component)`, `free(component)`, and `died(component, entity, world)`.
18
+ A component's data splits into `Config` (defining props supplied up front, e.g. `maxHealth`) and
19
+ `Serialization` (runtime-derived state, e.g. current `health`); `toBlock` sees `Config & Serialization` while
20
+ `save` returns only the `Serialization` slice. `free` runs when the component is torn down (see
21
+ [Freeing extra resources](#freeing-extra-resources)); `died` runs when the entity is killed, with it still
22
+ readable (see [Reacting to death](#reacting-to-death)).
21
23
  - **`ComponentRegistry<C>`** – the map of all component definitions for a game.
22
24
  - **`EntityFactory<C>`** – maps an entity `type` name to a base (template) config. Loading an entity layers
23
25
  the caller's config over its type's template, so shared static data lives in one place and a save only
24
- needs the `type` plus the entity's serialization. `BaseWorld#loadEntity` always goes through the factory.
26
+ needs the `type` plus the entity's serialization. Optionally, a template's immutable `class` selects a
27
+ game-provided `BaseEntity` subclass and restricts which components that template may load.
25
28
  - **`BaseWorld<C>`** – builds one `MemoryComponent` per registered component (attached to that component's
26
29
  definition as `world.registry[name].memoryComponent`) and runs systems. It is generic over your component
27
30
  map `C`, so `world.registry`, `entity.components`, `setComponent`, etc. are fully typed. Its entities live
28
31
  in `world.entities`, a `Map` keyed by `eid` — see [Iterating entities](#iterating-entities).
29
- - **`BaseEntity<C>`** – an `eid`, an optional `id`, and a bag of memory-backed components. It has no
30
- direct property accessors and only loads/saves component data. Every entity has an `entity` component
31
- whose `type` (a plain, worker-invisible string) records the factory template it was built from.
32
+ - **`BaseEntity<C>`** – an `eid`, its `id` alias, immutable `type`, `dead` state, and a bag of memory-backed
33
+ components. Every entity has an `entity` component whose immutable `type` and optional immutable `class`
34
+ are interned strings stored in shared memory. A game can subclass `BaseEntity` to expose direct convenience
35
+ getters for one entity class.
32
36
  - **Systems** – `System`, `IterableSystem`, `EntitySystem` (main-thread iteration over entities with a
33
- given set of components) and `ComponentSystem` (runs an update function over raw memory blocks,
34
- off-thread when Web Workers + `SharedArrayBuffer` are available).
37
+ given set of components), `EntityWorkerSystem` (runs an update function over raw memory blocks,
38
+ off-thread when Web Workers + `SharedArrayBuffer` are available), and `WorkerSystem` (an `EntityWorkerSystem`
39
+ that runs once per tick over its sub-queries instead of once per entity — see [Per-tick systems](#per-tick-systems-workersystem)).
35
40
 
36
41
  ## Defining components
37
42
 
@@ -129,10 +134,68 @@ and the `entity` block stores only a pointer to it at `TYPE_INDEX`. Reading `ent
129
134
  resolves that pointer back to the string through the world's cache; a worker can do the same (see
130
135
  [Reading an entity's type in a worker](#reading-an-entitys-type-in-a-worker)).
131
136
 
137
+ ### Entity classes and scoped templates
138
+
139
+ An entity `type` names one concrete template. Its optional `class` names the stable family that chooses a
140
+ wrapper constructor and the components that family permits. Callers still pass only `{ type, ...overrides }`:
141
+ the factory resolves class from the template, and neither identity can change after construction.
142
+
143
+ ```ts
144
+ import {
145
+ BaseEntity,
146
+ BaseWorld,
147
+ EntityFactory,
148
+ defineEntityClasses,
149
+ defineEntityConfigs,
150
+ } from '@daneren2005/shared-memory-ecs';
151
+ import type { ComponentsOf, EntityConfigOf, EntityInstancesOf } from '@daneren2005/shared-memory-ecs';
152
+
153
+ const registry = {
154
+ health: healthDefinition,
155
+ value: valueDefinition,
156
+ };
157
+ type Components = ComponentsOf<typeof registry>;
158
+ type Config = EntityConfigOf<typeof registry>;
159
+
160
+ class ActorEntity extends BaseEntity<Components, Config> {
161
+ get health() { return this.components.health?.health; }
162
+ }
163
+ class ItemEntity extends BaseEntity<Components, Config> {
164
+ get value() { return this.components.value?.value; }
165
+ }
166
+
167
+ const classes = defineEntityClasses(registry, {
168
+ actor: { entity: ActorEntity, components: ['health'] },
169
+ item: { entity: ItemEntity, components: ['value'] },
170
+ } as const);
171
+
172
+ const templates = defineEntityConfigs(classes, {
173
+ guardian: { type: 'guardian', class: 'actor', maxHealth: 100 },
174
+ token: { type: 'token', class: 'item', value: 5 },
175
+ } as const);
176
+
177
+ type Entities = EntityInstancesOf<typeof classes>;
178
+ const factory = new EntityFactory<Components, Config, Entities>(templates, classes);
179
+ const world = new BaseWorld<typeof registry, Components, Config, Entities>(registry, { factory });
180
+
181
+ const item = world.loadEntity({ type: 'token' });
182
+ console.log(item instanceof ItemEntity); // true
183
+ console.log(item.components.entity.class); // "item"
184
+ console.log(item.id, item.type, item.dead); // BaseEntity identity accessors
185
+ ```
186
+
187
+ `defineEntityConfigs` checks each template against its class's component definitions, so adding a health-only
188
+ property to the item template is a type error. The factory repeats the boundary at runtime: loading or attaching
189
+ a component outside the class allowlist throws, protecting dynamically loaded data too. Client/editor metadata
190
+ that no component consumes should remain in a separate authoring type and be transformed into the scoped ECS
191
+ template.
192
+
193
+ Classless factories keep the original behavior: they create `BaseEntity` and scan the complete registry.
194
+
132
195
  ## Iterating entities
133
196
 
134
197
  `world.entities` is a `Map` keyed by `eid`, not an array, and so is `entities` on `EntitySystem` and
135
- `ComponentSystem`:
198
+ `EntityWorkerSystem`:
136
199
 
137
200
  ```ts
138
201
  world.entities.forEach(entity => { ... }); // in the order they were added
@@ -186,25 +249,25 @@ physics library exports its `*_INDEX` constants.
186
249
  Reach for this when a profile says to, not by default. A menu, a save, a system that touches a dozen entities:
187
250
  use the accessors.
188
251
 
189
- ## ComponentSystem workers
252
+ ## EntityWorkerSystem workers
190
253
 
191
- `ComponentSystem` needs a `getWorker()` that returns a real `Worker`, and an `updateFunction`. Your
192
- worker entry file calls `createComponentWorker(self, updateFunction)`, importing it from the
254
+ `EntityWorkerSystem` needs a `getWorker()` that returns a real `Worker`, and an `updateFunction`. Your
255
+ worker entry file calls `createEntitySystemWorker(self, updateFunction)`, importing it from the
193
256
  [`/worker` subpath](#importing-in-workers) so the worker bundle stays small. When Web Workers or
194
257
  `SharedArrayBuffer` are unavailable it transparently falls back to running the same update function on
195
258
  the main thread. Attach any extra per-run data (the equivalent of the old faction/fog-of-war fields)
196
259
  by overriding `addDataToWorld(world)`. Declare its shape with the `W` type parameter (an interface
197
- extending `ComponentSystemWorld`) so both `addDataToWorld` and the `updateFunction` see it typed:
260
+ extending `EntityWorkerSystemWorld`) so both `addDataToWorld` and the `updateFunction` see it typed:
198
261
 
199
262
  ```ts
200
- interface DamageWorld extends ComponentSystemWorld {
263
+ interface DamageWorld extends EntityWorkerSystemWorld {
201
264
  damage: number
202
265
  }
203
266
 
204
267
  const damageUpdate: EntityUpdateFunction<Components, { health: Int32Array }, DamageWorld> =
205
268
  (world, entityId, components) => { components.health[0] -= world.damage; };
206
269
 
207
- class DamageSystem extends ComponentSystem<Components, { health: Int32Array }, DamageWorld> {
270
+ class DamageSystem extends EntityWorkerSystem<Components, { health: Int32Array }, DamageWorld> {
208
271
  addDataToWorld(world: DamageWorld) { world.damage = 5; }
209
272
  }
210
273
  ```
@@ -220,11 +283,11 @@ itself. Both are optional properties on the `EntityUpdateFunction`.
220
283
  (structured-cloned across the boundary) and returns a `Partial<W>` that is merged onto `world` on every
221
284
  subsequent run. Use it for state that must be seeded from the main thread but then persist inside the worker
222
285
  — a seeded RNG, a lookup table, a config object — without paying to re-send it each frame. Type the init
223
- data with the `D` type parameter (the fourth on `EntityUpdateFunction` / `ComponentSystem`) so `getInitData`
286
+ data with the `D` type parameter (the fourth on `EntityUpdateFunction` / `EntityWorkerSystem`) so `getInitData`
224
287
  and the `init` hook agree on its shape; it defaults to `unknown` when a system has no init data:
225
288
 
226
289
  ```ts
227
- interface DamageWorld extends ComponentSystemWorld {
290
+ interface DamageWorld extends EntityWorkerSystemWorld {
228
291
  damage: number
229
292
  }
230
293
  interface DamageInitData {
@@ -238,7 +301,7 @@ const damageUpdate: EntityUpdateFunction<Components, { health: Int32Array }, Dam
238
301
  // `DamageInitData | undefined` (undefined when the system supplies no getInitData).
239
302
  damageUpdate.init = (data) => ({ damage: data?.baseDamage ?? 1 });
240
303
 
241
- class DamageSystem extends ComponentSystem<Components, { health: Int32Array }, DamageWorld, DamageInitData> {
304
+ class DamageSystem extends EntityWorkerSystem<Components, { health: Int32Array }, DamageWorld, DamageInitData> {
242
305
  constructor(world: BaseWorld<Components>) {
243
306
  super(world, {
244
307
  name: 'DamageSystem',
@@ -253,7 +316,7 @@ class DamageSystem extends ComponentSystem<Components, { health: Int32Array }, D
253
316
  ```
254
317
 
255
318
  Unlike `addDataToWorld` (an overridable method on the system), `getInitData` is a config option — it lives in
256
- the options passed to the `ComponentSystem` constructor, so a system used without a subclass can supply it
319
+ the options passed to the `EntityWorkerSystem` constructor, so a system used without a subclass can supply it
257
320
  there directly.
258
321
 
259
322
  **`preRun`** runs once per run, before any entity is updated, with the run's `world`, the full entity list,
@@ -270,33 +333,77 @@ damageUpdate.preRun = (world, entities, queries, callbacks) => {
270
333
  (An `entityRemoved(world, entityId, callbacks)` hook completes the set — it fires once per entity that left
271
334
  the system this run, so a worker can release any per-entity state it was holding.)
272
335
 
336
+ ### Per-tick systems: `WorkerSystem`
337
+
338
+ Some systems have no entities of their own — they run **once per tick** to scan or count other entities, and
339
+ maybe spawn something. A `WorkerSystem` is an `EntityWorkerSystem` whose function is called **once per run** over
340
+ the named sub-queries instead of once per entity. It has no main query, so it runs every interval even with
341
+ zero entities, and it reuses the whole worker/query/`createsEntities`/`addDataToWorld` machinery:
342
+
343
+ ```ts
344
+ // once per run: no per-entity loop, just the queries
345
+ const spawnerUpdate: WorkerSystemRunFunction<Components, SpawnerWorld> = (world, queries, callbacks) => {
346
+ const asteroids = queries.asteroids ?? [];
347
+ if(asteroids.length < world.minAsteroids) {
348
+ createEntityWorker(world, { type: 'asteroid', x: 0, y: 0 }, callbacks);
349
+ }
350
+ };
351
+
352
+ class SpawnerSystem extends WorkerSystem<Components, SpawnerWorld> {
353
+ minAsteroids = 20;
354
+ constructor(world: TestWorld) {
355
+ super(world, {
356
+ name: 'Spawner',
357
+ updateFunction: spawnerUpdate,
358
+ createsEntities: true,
359
+ deltaBetweenRuns: 1000,
360
+ getWorker: () => new Worker(SPAWNER_WORKER_URL, { type: 'module' }),
361
+ queries: { asteroids: { required: ['asteroid'] } },
362
+ });
363
+ }
364
+ // Fresh per-run data — structured-cloned to the worker each run, so keep it plain/cloneable.
365
+ addDataToWorld(world: SpawnerWorld) { world.minAsteroids = this.minAsteroids; }
366
+ }
367
+ ```
368
+
369
+ Its worker entry uses `createSystemWorker` (the run-function counterpart of `createEntitySystemWorker`),
370
+ passing the registry only when it creates entities or adds components:
371
+
372
+ ```ts
373
+ import { createSystemWorker } from '@daneren2005/shared-memory-ecs/worker';
374
+ createSystemWorker(self, spawnerUpdate, registry);
375
+ ```
376
+
377
+ The run function may carry the same `init` / `entityRemoved` hooks as an `updateFunction`. Everything else —
378
+ callbacks, worker fallback, off-thread creation — behaves exactly as an `EntityWorkerSystem`.
379
+
273
380
  ### Importing in workers
274
381
 
275
382
  Each worker entry file is bundled on its own, and a single-entry bundle cannot tree-shake this package's
276
- barrel: importing `createComponentWorker` from `@daneren2005/shared-memory-ecs` drags the whole library -
383
+ barrel: importing `createEntitySystemWorker` from `@daneren2005/shared-memory-ecs` drags the whole library -
277
384
  `BaseWorld`, every system, their `@daneren2005/shared-memory-objects` dependencies - into the worker, even
278
385
  though a worker never runs any of it (easily ~20kb of dead code per worker). Import worker-side helpers from
279
386
  the `@daneren2005/shared-memory-ecs/worker` subpath instead. It exposes only what runs in a worker -
280
- `createComponentWorker`, `createEntityWorker`, `killEntityWorker`, `DEAD_INDEX`, `TYPE_INDEX` (plus the
281
- worker-relevant types) - so the bundle stays tiny:
387
+ `createEntitySystemWorker`, `createSystemWorker`, `createEntityWorker`, `addComponentWorker`,
388
+ `removeComponentWorker`, `killEntityWorker`, `DEAD_INDEX`,
389
+ `TYPE_INDEX` (plus the worker-relevant types) - so the bundle stays tiny:
282
390
 
283
391
  ```ts
284
392
  // damage.worker.ts - the worker entry file
285
- import { createComponentWorker } from '@daneren2005/shared-memory-ecs/worker';
393
+ import { createEntitySystemWorker } from '@daneren2005/shared-memory-ecs/worker';
286
394
  import { damageUpdate } from './damage-update';
287
395
 
288
- createComponentWorker(self, damageUpdate);
396
+ createEntitySystemWorker(self, damageUpdate);
289
397
  ```
290
398
 
291
399
  The same applies to any module the worker file pulls in: an update function that calls `createEntityWorker`
292
400
  or `killEntityWorker` should import them from `/worker` too. Type-only imports (`EntityUpdateFunction`,
293
- `ComponentSystemWorld`, ...) can come from either path since types are erased, and main-thread code
294
- (`ComponentSystem`, `BaseWorld`, `EntityFactory`, ...) keeps importing from the package root.
401
+ `EntityWorkerSystemWorld`, ...) can come from either path since types are erased, and main-thread code
402
+ (`EntityWorkerSystem`, `BaseWorld`, `EntityFactory`, ...) keeps importing from the package root.
295
403
 
296
- A worker that creates entities (see [Creating entities from a worker](#creating-entities-from-a-worker)) passes
297
- your component registry as the third argument - `createComponentWorker(self, shipUpdate, registry)` - so it has
298
- each component's `toBlock`. Only do this in workers that actually create entities; it pulls the registry (and
299
- whatever it imports) into that worker's bundle.
404
+ A worker that creates entities or adds components passes your component registry as the third argument -
405
+ `createEntitySystemWorker(self, shipUpdate, registry)` - so it has each component's `toBlock`. Only do this in
406
+ workers that need it; it pulls the registry (and whatever it imports) into that worker's bundle.
300
407
 
301
408
  ### Reading an entity's type in a worker
302
409
 
@@ -314,7 +421,7 @@ const update: EntityUpdateFunction<Components, { entity: Uint32Array }> = (world
314
421
  // ...branch on type, etc.
315
422
  };
316
423
 
317
- class TypedSystem extends ComponentSystem<Components, { entity: Uint32Array }> {
424
+ class TypedSystem extends EntityWorkerSystem<Components, { entity: Uint32Array }> {
318
425
  constructor(world: BaseWorld<Components>) {
319
426
  super(world, { name: 'TypedSystem', required: ['entity'], updateFunction: update, getWorker: () => new Worker(/* ... */) });
320
427
  }
@@ -331,7 +438,8 @@ on the main thread) resolves the same way.
331
438
  An update function runs on shared memory, so anything it writes is already visible on the main thread. What
332
439
  it cannot do from there is touch the world, so the things that have to happen back on it go through
333
440
  `callbacks`: `entityComponentChanged` (emitted on the entity as `component-property-updated`), `entityDied`
334
- (as `death`), and `createEntity` (see [Creating entities from a worker](#creating-entities-from-a-worker)).
441
+ (as `death`), `createEntity` (see [Creating entities from a worker](#creating-entities-from-a-worker)), and
442
+ component changes requested by `addComponentWorker` / `removeComponentWorker`.
335
443
  All of them are collected during the run and applied once it completes.
336
444
 
337
445
  `emitEntityEvent` is the escape hatch for an event of your own: name it whatever you like and give it
@@ -401,20 +509,22 @@ createEntityWorker(world, { type: 'ship', x: 100, y: 150 }, callbacks);
401
509
 
402
510
  `createEntityWorker` layers your overrides over the `ship` template, mints a unique id from a shared atomic
403
511
  counter (so it never collides with one the main thread or another worker hands out), and for each component
404
- the merged config triggers, pushes a block into its pool and writes the values - all off-thread. It reports
512
+ the merged config triggers, pushes a block into its pool and writes the values - all off-thread. With entity
513
+ classes registered, the worker also resolves the template's class and considers only its allowed components. It reports
405
514
  back an id-plus-block-indexes descriptor; when the run completes the main thread *adopts* it, building the
406
- always-present `entity` component there (interning the `type` is a main-thread job) and wrapping each block the
407
- worker wrote - no block is copied or re-allocated. Like every other worker report-back, the new entity first
515
+ always-present `entity` component there (interning `type` and `class` is a main-thread job), asking the factory
516
+ for the registered wrapper, and attaching each block the worker wrote - no block is copied or re-allocated. Like
517
+ every other worker report-back, the new entity first
408
518
  exists on the following frame, so the system picks it up next run.
409
519
 
410
520
  Two things make this work, both opt-in so only the workers that create entities pay for them:
411
521
 
412
522
  - Register the system with `createsEntities: true`. That ships the factory templates to its worker on load.
413
- - In that system's worker entry, pass your component registry to `createComponentWorker`, so the worker has
523
+ - In that system's worker entry, pass your component registry to `createEntitySystemWorker`, so the worker has
414
524
  each component's block builder:
415
525
 
416
526
  ```ts
417
- createComponentWorker(self, shipUpdate, registry);
527
+ createEntitySystemWorker(self, shipUpdate, registry);
418
528
  ```
419
529
 
420
530
  Every component is already defined as two halves for exactly this — `toBlock(config)` (the block values) and
@@ -436,9 +546,48 @@ const shipHealth: ComponentDefinition<HealthComponent, Int32Array, HealthConfig>
436
546
  };
437
547
  ```
438
548
 
439
- This runs identically on the main-thread fallback. Two current limits: a `loadInFinishLoading` component (one
440
- that reads other entities) is skipped, since the worker has no world to read; and an adopted entity is always
441
- the base `BaseEntity` - a factory per-type subclass is not applied to it.
549
+ This runs identically on the main-thread fallback. A `loadInFinishLoading` component (one that reads other
550
+ entities) is skipped, since the worker has no world to read. A worker does not receive constructors; it returns
551
+ the serializable class key, and the main-thread factory constructs the wrapper during adoption.
552
+
553
+ ### Adding and removing components at runtime
554
+
555
+ On the main thread, use the entity directly:
556
+
557
+ ```ts
558
+ ship.loadComponent('shield', { capacity: 100 });
559
+ ship.removeComponent('shield');
560
+ ```
561
+
562
+ Both operations notify every system. Required and excluded (`not`) queries change membership as needed. An
563
+ entity already present through other required components is re-sent to worker systems too, so their cached
564
+ component bundle gains or loses an optional component on the next run.
565
+
566
+ Inside an update function, use the worker helpers instead. They record structural changes without changing the
567
+ component snapshot currently being iterated:
568
+
569
+ ```ts
570
+ import { addComponentWorker, removeComponentWorker } from '@daneren2005/shared-memory-ecs/worker';
571
+
572
+ if(equippedShieldGenerator && !components.shield) {
573
+ addComponentWorker(world, entityId, 'shield', { capacity: 100 }, callbacks);
574
+ } else if(!equippedShieldGenerator && components.shield) {
575
+ removeComponentWorker(entityId, 'shield', callbacks);
576
+ }
577
+ ```
578
+
579
+ The main thread applies those commands after the worker run completes, emits the normal component-added or
580
+ component-removed event, and queues query deltas. The originating run therefore keeps a stable snapshot and all
581
+ systems see the new structure no later than their next run. Removing a component uses the same deferred-free
582
+ safety described below.
583
+
584
+ Worker-side addition needs two opt-ins because it allocates a block:
585
+
586
+ - Set `addsComponents: true` on the `EntityWorkerSystem`.
587
+ - Pass the component registry to that system's `createEntitySystemWorker(self, update, registry)` call.
588
+
589
+ `addComponentWorker` calls the definition's worker-safe `toBlock(config)` and cannot add the always-present
590
+ `entity` component or a `loadInFinishLoading` component. Worker-side removal needs no allocation opt-in.
442
591
 
443
592
  ### Freeing component memory safely
444
593
 
@@ -485,6 +634,35 @@ It is deferred to the same safe point as the component's own block (see above),
485
634
  entity dies — so a system still mid-run over that memory can't see your resource released early. Reload calls
486
635
  it without any `death` event, so it is the reliable place to avoid leaks when a world is reused.
487
636
 
637
+ ### Reacting to death
638
+
639
+ To *react* when an entity dies — spawn drops at its position, credit a kill, play an effect — a component
640
+ definition can carry an optional `died(component, entity, world)`. It fires exactly once per death, on the main
641
+ thread, with the dying entity **still fully readable** (its blocks are only *deferred*-freed, never torn down
642
+ before the hook runs), for both a worker kill (`killEntityWorker`) and a main-thread `killEntity`:
643
+
644
+ ```ts
645
+ const oreDepositDefinition: ComponentDefinition<OreDeposit, Int32Array, OreDepositConfig> = {
646
+ type: Int32Array,
647
+ size: 3,
648
+ loadProperties: ['oreAmount'],
649
+ toBlock(config) { /* ... */ },
650
+ attach(entity, memory, index) { /* ... */ },
651
+ died(component, entity, world) {
652
+ // The asteroid is still readable here, so spawn its drop where it was.
653
+ const { x, y } = entity.components.position!;
654
+ world.loadEntity({ type: 'Ore', x, y, amount: component.oreAmount });
655
+ },
656
+ };
657
+ ```
658
+
659
+ The hook is engine-agnostic: attach death behavior to the component that represents it. A throwing `died` can't
660
+ abort the death — it is caught and surfaced as a `system-error` event (`phase: 'died'`) while the entity is still
661
+ removed. Unlike `free`, `died` fires **only** on a real death, never on `world.load()`/`clear()` teardown.
662
+
663
+ For a non-declarative hook, the world also emits `world.on('entity-died', entity)` at the same point (distinct
664
+ from `entity-removed`, which fires for every removal, reloads included).
665
+
488
666
  ### Reusing a world: `load` and `clear`
489
667
 
490
668
  A world is meant to be reused rather than rebuilt. `world.load(config)` swaps in a fresh scenario — it removes
@@ -0,0 +1,3 @@
1
+ import type { ComponentMap } from '../component-definition';
2
+ import type { EntityWorkerSystemCallbacks, EntityWorkerSystemWorld } from '../systems/entity-worker-system';
3
+ export default function addComponentWorker<C extends ComponentMap, K extends keyof C>(world: EntityWorkerSystemWorld, entityId: number, componentName: K, config: Record<string, unknown>, callbacks: EntityWorkerSystemCallbacks<C>): void;
@@ -1,4 +1,5 @@
1
- import type { WorkerAllocator, WorkerCreateEntityConfig, WorkerCreatedEntity } from '../systems/component-system';
1
+ import type { WorkerAllocator, WorkerCreateEntityConfig, WorkerCreatedComponent, WorkerCreatedEntity } from '../systems/entity-worker-system';
2
+ import type { WorkerEntityClassRegistry } from '../entity-class';
2
3
  export interface WorkerCreatableComponent {
3
4
  loadProperties: Array<string>;
4
5
  loadInFinishLoading?: boolean;
@@ -10,4 +11,5 @@ export type WorkerCreateRegistry = {
10
11
  export type FactoryConfigs = {
11
12
  [type: string]: Record<string, unknown>;
12
13
  };
13
- export declare function buildWorkerEntity(config: WorkerCreateEntityConfig, factoryConfigs: FactoryConfigs, registry: WorkerCreateRegistry, allocator: WorkerAllocator): WorkerCreatedEntity;
14
+ export declare function buildWorkerEntity(config: WorkerCreateEntityConfig, factoryConfigs: FactoryConfigs, registry: WorkerCreateRegistry, allocator: WorkerAllocator, classes?: WorkerEntityClassRegistry): WorkerCreatedEntity;
15
+ export declare function buildWorkerComponent(name: string, config: Record<string, unknown>, registry: WorkerCreateRegistry, allocator: WorkerAllocator): WorkerCreatedComponent;
@@ -1,2 +1,2 @@
1
- import type { ComponentSystemCallbacks, ComponentSystemWorld, WorkerCreateEntityConfig } from '../systems/component-system';
2
- export default function createEntityWorker(world: ComponentSystemWorld, config: WorkerCreateEntityConfig, callbacks: ComponentSystemCallbacks): void;
1
+ import type { EntityWorkerSystemCallbacks, EntityWorkerSystemWorld, WorkerCreateEntityConfig } from '../systems/entity-worker-system';
2
+ export default function createEntityWorker(world: EntityWorkerSystemWorld, config: WorkerCreateEntityConfig, callbacks: EntityWorkerSystemCallbacks): void;
@@ -1,2 +1,2 @@
1
- import type { ComponentSystemCallbacks, EntityUpdateComponents } from '../systems/component-system';
2
- export default function killEntityWorker(entityId: number, components: EntityUpdateComponents, callbacks: ComponentSystemCallbacks): void;
1
+ import type { EntityWorkerSystemCallbacks, EntityUpdateComponents } from '../systems/entity-worker-system';
2
+ export default function killEntityWorker(entityId: number, components: EntityUpdateComponents, callbacks: EntityWorkerSystemCallbacks): void;
@@ -0,0 +1,3 @@
1
+ import type { ComponentMap } from '../component-definition';
2
+ import type { EntityWorkerSystemCallbacks } from '../systems/entity-worker-system';
3
+ export default function removeComponentWorker<C extends ComponentMap, K extends keyof C>(entityId: number, componentName: K, callbacks: EntityWorkerSystemCallbacks<C>): void;
@@ -2,6 +2,7 @@ import type { TypedArrayConstructor } from '@daneren2005/shared-memory-objects/i
2
2
  import type MemoryComponent from './memory-component';
3
3
  import type { ComponentTypedArray } from './memory-component';
4
4
  import type BaseEntity from './entity';
5
+ import type BaseWorld from './world';
5
6
  import type { EntityComponent, EntityComponentConfig, EntityComponentSerialization } from './entity-component';
6
7
  export interface BaseComponent {
7
8
  index: number;
@@ -17,6 +18,7 @@ export interface ComponentDefinition<Component extends BaseComponent, T extends
17
18
  attach(entity: BaseEntity, memory: MemoryComponent<T>, index: number): Component;
18
19
  save?(component: Component): Serialization;
19
20
  free?(component: Component): void;
21
+ died?(component: Component, entity: BaseEntity, world: BaseWorld): void;
20
22
  }
21
23
  export type RegisteredComponentDefinition<Component extends BaseComponent, T extends ComponentTypedArray = ComponentTypedArray, Config = any, Serialization = object> = ComponentDefinition<Component, T, Config, Serialization> & {
22
24
  memoryComponent: MemoryComponent<T>;