@daneren2005/shared-memory-ecs 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Scott Jackson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -20,7 +20,8 @@ fog of war, sub-classed entities, required components, etc).
20
20
  needs the `type` plus the entity's serialization. `BaseWorld#loadEntity` always goes through the factory.
21
21
  - **`BaseWorld<C>`** – builds one `MemoryComponent` per registered component (attached to that component's
22
22
  definition as `world.registry[name].memoryComponent`) and runs systems. It is generic over your component
23
- map `C`, so `world.registry`, `entity.components`, `setComponent`, etc. are fully typed.
23
+ map `C`, so `world.registry`, `entity.components`, `setComponent`, etc. are fully typed. Its entities live
24
+ in `world.entities`, a `Map` keyed by `eid` — see [Iterating entities](#iterating-entities).
24
25
  - **`BaseEntity<C>`** – an `eid`, an optional `id`, and a bag of memory-backed components. It has no
25
26
  direct property accessors and only loads/saves component data. Every entity has an `entity` component
26
27
  whose `type` (a plain, worker-invisible string) records the factory template it was built from.
@@ -109,6 +110,63 @@ console.log(goblin.save()); // { type: 'goblin', health: 10 } - no templated max
109
110
  world.loadEntity(goblin.save());
110
111
  ```
111
112
 
113
+ ## Iterating entities
114
+
115
+ `world.entities` is a `Map` keyed by `eid`, not an array, and so is `entities` on `EntitySystem` and
116
+ `ComponentSystem`:
117
+
118
+ ```ts
119
+ world.entities.forEach(entity => { ... }); // in the order they were added
120
+ world.entities.get(eid); // same as world.getEntityByEid(eid)
121
+ world.entities.size;
122
+ for(const entity of world.entities.values()) { ... }
123
+
124
+ // For the array methods a Map does not have:
125
+ Array.from(world.entities.values()).filter(entity => !!entity.components.health);
126
+ ```
127
+
128
+ An entity leaves from anywhere in the middle of that collection — every death is one — and finding it in an
129
+ array meant a scan of the whole world, then a shift of everything after it, repeated for each system that
130
+ held it as well. A few thousand entities with a few dozen deaths a run made that the most expensive thing the
131
+ main thread did on a busy frame; a `Map` deletes in constant time. It also replaced the separate
132
+ `entitiesByEid` lookup, so there is only ever one collection to keep straight.
133
+
134
+ Iteration order is still insertion order, so `load` / `save` round-trip in the order they always did.
135
+
136
+ ## Reading components on a hot path
137
+
138
+ `entity.components.health!.health` is the way to read a component, and for almost everything it is the right
139
+ one: it is typed, it is readable, and one read costs nothing worth measuring.
140
+
141
+ It is not free, though, and the cost shows up in exactly one situation — reading the same component off
142
+ *thousands* of entities, *every frame*. The accessor walks several objects to get there and ends in a getter
143
+ closure over the shared block, and because every entity has its own closure those call sites go megamorphic
144
+ once enough entities are alive, so none of it inlines. Measured over ~10,000 entities, reading four values per
145
+ entity cost **~950ns through the accessors against ~140ns straight off the block** — the difference between
146
+ 10ms a frame and 1.5ms.
147
+
148
+ Where that matters, hold the block instead. It is the same memory the accessors read, so nothing changes about
149
+ what you get, and it is what the update functions already work on:
150
+
151
+ ```ts
152
+ import { TRANSFORM_X_INDEX } from '@daneren2005/shared-memory-physics';
153
+
154
+ // Resolve once, when whatever is doing the reading is set up.
155
+ const health = entity.components.health!;
156
+ const block = world.registry.health.memoryComponent.getBlock(health.index) as Int32Array;
157
+
158
+ // Then per frame, per entity:
159
+ block[HEALTH_INDEX];
160
+ ```
161
+
162
+ Two things come with that. The block is only valid while the component is: resolve it again if the component
163
+ can be removed and re-added, or hang it off something that dies with the entity. And it is indexed rather than
164
+ named, so the offsets have to be exported alongside the definition — which is why every component in the
165
+ physics library exports its `*_INDEX` constants.
166
+
167
+ Reach for this when a profile says to, not by default. A menu, a save, a system that touches a dozen entities:
168
+ use the accessors.
169
+
112
170
  ## ComponentSystem workers
113
171
 
114
172
  `ComponentSystem` needs a `getWorker()` that returns a real `Worker`, and an `updateFunction`. Your
@@ -131,6 +189,98 @@ class DamageSystem extends ComponentSystem<Components, { health: Int32Array }, D
131
189
  }
132
190
  ```
133
191
 
192
+ ### Reporting back to the main thread
193
+
194
+ An update function runs on shared memory, so anything it writes is already visible on the main thread. What
195
+ it cannot do from there is touch the world, so the things that have to happen back on it go through
196
+ `callbacks`: `entityComponentChanged` (emitted on the entity as `component-property-updated`), `entityDied`
197
+ (as `death`), and `createEntity`. All of them are collected during the run and applied once it completes.
198
+
199
+ `emitEntityEvent` is the escape hatch for an event of your own: name it whatever you like and give it
200
+ whatever args suit it, and it is emitted on the entity under that name. It exists so a system does not have
201
+ to spend one `component-property-updated` per property when the listener only cares about all of them
202
+ together - a move that reports `x` and `y` as one `position-updated` is half the events of one per axis:
203
+
204
+ ```ts
205
+ // in the update function
206
+ callbacks.emitEntityEvent(entityId, 'position-updated', x, y);
207
+
208
+ // on the main thread
209
+ entity.on('position-updated', (x: number, y: number) => { ... });
210
+ ```
211
+
212
+ The args are structured-cloned across the worker boundary, so they have to be plain values - no functions,
213
+ no class instances. Nothing about the name or the args is checked against your component map, since the
214
+ event is the system's own concept rather than a component, so export both alongside the update function that
215
+ emits them.
216
+
217
+ ### Reporting something that happens to everything, every run
218
+
219
+ Every callback above costs an event object per entity: an allocation in the worker, a structured clone of it,
220
+ an eid lookup on the main thread, and an emit on that entity. That is fine for a death or a hit, which happen
221
+ to a handful of entities a run. It is not fine for a move, which happens to nearly all of them, every run -
222
+ at ten thousand entities that is ten thousand objects and ten thousand emits a frame, and it can easily cost
223
+ more than the simulation it is reporting.
224
+
225
+ `emitSystemEvent` is the version of that with everything avoidable taken out. Name the event and give it an
226
+ entity id; the whole run arrives on the **system** as one call with one array of ids:
227
+
228
+ ```ts
229
+ // in the update function - just the id, once per entity that moved
230
+ callbacks.emitSystemEvent('position-updated', entityId);
231
+
232
+ // on the main thread - one call for the run, however many entities are in it
233
+ system.on('position-updated', (entityIds: Array<number>) => {
234
+ for(const eid of entityIds) {
235
+ const sprite = sprites.get(eid);
236
+ // The worker wrote the position into shared memory, so it is already here to read.
237
+ if(sprite) {
238
+ const transform = world.getEntityByEid(eid)?.components.transform;
239
+ ...
240
+ }
241
+ }
242
+ });
243
+ ```
244
+
245
+ Nothing travels with the id on purpose. The blocks the update just wrote are shared memory, so the main
246
+ thread already holds the values - sending them along would only pay to copy what is already there. Reach for
247
+ `emitEntityEvent` when the thing you want to report is *not* in a component block, and for this when it is.
248
+
249
+ System events are dispatched before the per-entity events of the same run, so an entity a run both moved and
250
+ killed is still in the world when its move is reported.
251
+
252
+ ## Measuring performance
253
+
254
+ `PerformanceTiming` watches a world and reports what running it costs. Hand it the world and it hooks itself
255
+ up to the events the world already emits - there is nothing to call per frame and nothing added to the hot
256
+ path:
257
+
258
+ ```ts
259
+ const timing = new PerformanceTiming(world);
260
+ timing.on('stats-updated', (stats: PerformanceStats) => renderDebugPanel(stats));
261
+ ```
262
+
263
+ It gathers samples every frame and collapses them into a fresh `timing.stats` snapshot once
264
+ `ticksBetweenUpdates` (default `1_000`) worth of elapsed time has gone by, then emits `stats-updated` with it.
265
+ The window is measured in whatever unit you drive `world.update` with, so a game running on milliseconds gets
266
+ a snapshot a second. Frames the world was paused for are skipped, since it does no work on them.
267
+
268
+ Every entry is an `{ avg, min, max, samples }` over the window just closed:
269
+
270
+ - `stats.update` - one whole `world.update` call on the thread the world lives on.
271
+ - `stats.systems[]` - per system, in run order: `run` is the run itself on its worker, as the worker measured
272
+ it, and `events` is what handling that run's results (the events it reported onto entities, the entities it
273
+ asked to be created) cost back on the calling thread. A system running on the main-thread fallback never
274
+ reports either, so it sits at zero with `samples: 0` - which is what tells it apart from one that genuinely
275
+ cost nothing.
276
+ - `stats.events` - every system's event handling added together. Workers finish on their own schedule rather
277
+ than on a frame boundary, so there is no per-frame combined sample to take; these are the per-system figures
278
+ summed at the end of the window, giving what a run of every system costs the main thread between them.
279
+
280
+ `getSystemStats(name)` pulls one system out of the latest snapshot, `reset()` throws away everything collected
281
+ so far (worth doing after loading a new scene, when the samples either side are not comparable), and
282
+ `destroy()` unhooks it from the world.
283
+
134
284
  ## Building
135
285
 
136
286
  ```sh
package/dist/index.d.ts CHANGED
@@ -7,6 +7,8 @@ export type { EntityComponent, EntityComponentConfig, EntityComponentSerializati
7
7
  export { default as killEntity } from './actions/kill-entity';
8
8
  export { default as killEntityWorker } from './actions/kill-entity-worker';
9
9
  export { default as createEntityWorker } from './actions/create-entity-worker';
10
+ export { default as PerformanceTiming, DEFAULT_TICKS_BETWEEN_UPDATES } from './performance-timing';
11
+ export type { TimingStats, SystemTimingStats, PerformanceStats, PerformanceTimingOptions, } from './performance-timing';
10
12
  export { default as MemoryComponent } from './memory-component';
11
13
  export type { ComponentTypedArray } from './memory-component';
12
14
  export type { BaseComponent, ComponentMap, ComponentDefinition, ComponentDefinitionMap, ComponentRegistry, ComponentsOf, EntityConfigOf, RegisteredComponentDefinition, RegisteredComponentRegistry, } from './component-definition';
@@ -22,3 +24,4 @@ export { default as WebWorker } from './systems/workers/web-worker';
22
24
  export { default as ComponentWebWorker } from './systems/workers/component-web-worker';
23
25
  export { default as createComponentWorker } from './systems/workers/create-component-worker';
24
26
  export type { default as ComponentWorkerMessage } from './systems/workers/component-worker-message';
27
+ export type { EntityEvent, SystemEvents } from './systems/workers/component-worker-message';