archetype-ecs 1.2.0 → 1.3.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 (48) hide show
  1. package/README.md +126 -63
  2. package/bench/allocations-1m.js +1 -1
  3. package/bench/multi-ecs-bench.js +1 -1
  4. package/bench/typed-vs-bitecs-1m.js +1 -1
  5. package/bench/typed-vs-untyped.js +81 -81
  6. package/bench/vs-bitecs.js +31 -56
  7. package/dist/ComponentRegistry.d.ts +18 -0
  8. package/dist/ComponentRegistry.js +29 -0
  9. package/dist/EntityManager.d.ts +52 -0
  10. package/dist/EntityManager.js +891 -0
  11. package/dist/Profiler.d.ts +12 -0
  12. package/dist/Profiler.js +38 -0
  13. package/dist/System.d.ts +41 -0
  14. package/dist/System.js +159 -0
  15. package/dist/index.d.ts +12 -0
  16. package/dist/index.js +29 -0
  17. package/dist/src/ComponentRegistry.d.ts +18 -0
  18. package/dist/src/ComponentRegistry.js +29 -0
  19. package/dist/src/EntityManager.d.ts +49 -0
  20. package/dist/src/EntityManager.js +853 -0
  21. package/dist/src/Profiler.d.ts +12 -0
  22. package/dist/src/Profiler.js +38 -0
  23. package/dist/src/System.d.ts +37 -0
  24. package/dist/src/System.js +139 -0
  25. package/dist/src/index.d.ts +14 -0
  26. package/dist/src/index.js +29 -0
  27. package/dist/tests/EntityManager.test.d.ts +1 -0
  28. package/dist/tests/EntityManager.test.js +651 -0
  29. package/dist/tests/System.test.d.ts +1 -0
  30. package/dist/tests/System.test.js +630 -0
  31. package/dist/tests/types.d.ts +1 -0
  32. package/dist/tests/types.js +129 -0
  33. package/package.json +9 -7
  34. package/src/ComponentRegistry.ts +49 -0
  35. package/src/EntityManager.ts +1018 -0
  36. package/src/{Profiler.js → Profiler.ts} +18 -5
  37. package/src/System.ts +226 -0
  38. package/src/index.ts +44 -0
  39. package/tests/{EntityManager.test.js → EntityManager.test.ts} +338 -70
  40. package/tests/System.test.ts +730 -0
  41. package/tests/types.ts +67 -66
  42. package/tsconfig.json +8 -5
  43. package/tsconfig.test.json +13 -0
  44. package/.claude/settings.local.json +0 -32
  45. package/src/ComponentRegistry.js +0 -21
  46. package/src/EntityManager.js +0 -462
  47. package/src/index.d.ts +0 -111
  48. package/src/index.js +0 -37
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  <br><br>
5
5
  <strong>archetype-ecs</strong>
6
6
  <br>
7
- <sub>Tiny, fast ECS with TypedArray storage. Zero dependencies.</sub>
7
+ <sub>ECS with TypedArray storage. No dependencies.</sub>
8
8
  <br><br>
9
9
  <a href="https://www.npmjs.com/package/archetype-ecs"><img src="https://img.shields.io/npm/v/archetype-ecs.svg?style=flat-square&color=000" alt="npm" /></a>
10
10
  <img src="https://img.shields.io/badge/gzip-~5kb-000?style=flat-square" alt="size" />
@@ -13,16 +13,12 @@
13
13
 
14
14
  ---
15
15
 
16
- Entities grouped by component composition. Numeric fields in contiguous TypedArrays, strings in SoA arrays. Bitmask query matching. Zero-allocation hot paths.
16
+ An Entity Component System for games and simulations in TypeScript. Entities with the same components are grouped into archetypes, and their fields are stored in TypedArrays so iterating a million entities is a tight loop over contiguous memory, not a scatter of object lookups.
17
17
 
18
18
  ```
19
19
  npm i archetype-ecs
20
20
  ```
21
21
 
22
- ---
23
-
24
- ### The full picture in 20 lines
25
-
26
22
  ```ts
27
23
  import { createEntityManager, component } from 'archetype-ecs'
28
24
 
@@ -50,18 +46,16 @@ em.forEach([Position, Velocity], (arch) => {
50
46
  })
51
47
  ```
52
48
 
53
- Define components, spawn entities, iterate with raw TypedArrays — no allocations, no cache misses, full type safety.
54
-
55
49
  ---
56
50
 
57
51
  ### Why archetype-ecs?
58
52
 
59
53
  <table>
60
- <tr><td><strong>Fastest iteration</strong></td><td>SoA TypedArrays give the fastest iteration of any JS ECS we've tested. 1.7 ms/frame over 1M entities — see <a href="#benchmarks">benchmarks</a>.</td></tr>
61
- <tr><td><strong>Compact memory</strong></td><td>Packed archetypes store 1M entities in 86 MB. Up to 2.4x less than sparse-array alternatives.</td></tr>
62
- <tr><td><strong>Zero-alloc hot path</strong></td><td><code>em.get</code>, <code>em.set</code>, and <code>forEach</code> never allocate. Your GC stays quiet.</td></tr>
63
- <tr><td><strong>Type-safe</strong></td><td>Full TypeScript generics. Field names autocomplete. Wrong fields don't compile.</td></tr>
64
- <tr><td><strong>Zero dependencies</strong></td><td>~5kb gzipped. No build step. Ships as ES modules.</td></tr>
54
+ <tr><td><strong>Fast iteration</strong></td><td>1.7 ms/frame over 1M entities. Faster than bitecs, wolf-ecs, harmony-ecs — see <a href="#benchmarks">benchmarks</a>.</td></tr>
55
+ <tr><td><strong>Low memory</strong></td><td>86 MB for 1M entities. Sparse-array ECS libraries use up to 2.4x more.</td></tr>
56
+ <tr><td><strong>No allocations</strong></td><td><code>get</code>, <code>set</code>, and <code>forEach</code> don't allocate.</td></tr>
57
+ <tr><td><strong>Typed</strong></td><td>TypeScript generics throughout. Field names autocomplete, wrong fields don't compile.</td></tr>
58
+ <tr><td><strong>Systems</strong></td><td>Class-based systems with <code>@OnAdded</code> / <code>@OnRemoved</code> decorators. Functional API also available.</td></tr>
65
59
  </table>
66
60
 
67
61
  ---
@@ -71,12 +65,12 @@ Define components, spawn entities, iterate with raw TypedArrays — no allocatio
71
65
  ```ts
72
66
  import { createEntityManager, component } from 'archetype-ecs'
73
67
 
74
- // Numeric — backed by TypedArrays for cache-friendly iteration
68
+ // Numeric — stored as TypedArrays
75
69
  const Position = component('Position', 'f32', ['x', 'y'])
76
70
  const Velocity = component('Velocity', 'f32', ['vx', 'vy'])
77
71
  const Health = component('Health', { hp: 'i32', maxHp: 'i32' })
78
72
 
79
- // Strings — backed by SoA arrays, same field access API
73
+ // Strings — stored as arrays, same API
80
74
  const Name = component('Name', 'string', ['name', 'title'])
81
75
 
82
76
  // Mixed — numeric and string fields in one component
@@ -90,7 +84,7 @@ const Enemy = component('Enemy')
90
84
 
91
85
  ### Entities
92
86
 
93
- ```js
87
+ ```ts
94
88
  const em = createEntityManager()
95
89
 
96
90
  // One at a time
@@ -100,7 +94,7 @@ em.addComponent(player, Velocity, { vx: 0, vy: 0 })
100
94
  em.addComponent(player, Health, { hp: 100, maxHp: 100 })
101
95
  em.addComponent(player, Name, { name: 'Hero', title: 'Sir' })
102
96
 
103
- // Or all at once — no archetype migration overhead
97
+ // Or all at once
104
98
  for (let i = 0; i < 10_000; i++) {
105
99
  em.createEntityWith(
106
100
  Position, { x: Math.random() * 800, y: Math.random() * 600 },
@@ -116,8 +110,8 @@ em.destroyEntity(player)
116
110
 
117
111
  ### Read & write
118
112
 
119
- ```js
120
- // Zero allocation access any field directly
113
+ ```ts
114
+ // Access a single field (doesn't allocate)
121
115
  em.get(player, Position.x) // 0
122
116
  em.get(player, Name.name) // 'Hero'
123
117
  em.set(player, Velocity.vx, 5)
@@ -127,16 +121,16 @@ em.getComponent(player, Position) // { x: 0, y: 0 }
127
121
  em.getComponent(player, Name) // { name: 'Hero', title: 'Sir' }
128
122
  ```
129
123
 
130
- ### Systems — `forEach` vs `query`
124
+ ### Queries — `forEach` vs `query`
131
125
 
132
126
  Two ways to work with entities in bulk. Pick the right one for the job:
133
127
 
134
- #### `forEach` — zero-alloc bulk processing
128
+ #### `forEach` — bulk processing
135
129
 
136
- Best for **systems that run every frame**. Gives you raw TypedArrays — no entity lookups, no object allocations, no cache misses.
130
+ Iterates over matching archetypes. You get the backing TypedArrays directly.
137
131
 
138
- ```js
139
- function movementSystem(dt) {
132
+ ```ts
133
+ function movementSystem(dt: number) {
140
134
  em.forEach([Position, Velocity], (arch) => {
141
135
  const px = arch.field(Position.x) // Float32Array
142
136
  const py = arch.field(Position.y)
@@ -152,9 +146,9 @@ function movementSystem(dt) {
152
146
 
153
147
  #### `query` — when you need entity IDs
154
148
 
155
- Best for **event-driven logic** where you need to store, pass around, or target specific entity IDs.
149
+ Returns entity IDs for when you need to target specific entities.
156
150
 
157
- ```js
151
+ ```ts
158
152
  // Find the closest enemy to the player
159
153
  const enemies = em.query([Position, Enemy])
160
154
  let closest = -1, minDist = Infinity
@@ -182,11 +176,69 @@ const total = em.count([Position])
182
176
  | **Use for** | Movement, physics, rendering | Damage events, UI, spawning |
183
177
  | **Runs** | Every frame | On demand |
184
178
  | **Allocates** | Nothing | `number[]` of entity IDs |
185
- | **Access** | Raw TypedArrays by field | `get` / `set` by entity ID |
179
+ | **Access** | TypedArrays by field | `get` / `set` by entity ID |
180
+
181
+ ### Systems
182
+
183
+ Class-based systems with decorators for component lifecycle hooks:
184
+
185
+ ```ts
186
+ import { System, OnAdded, OnRemoved, createSystems, type EntityId } from 'archetype-ecs'
187
+
188
+ class MovementSystem extends System {
189
+ tick() {
190
+ this.forEach([Position, Velocity], (arch) => {
191
+ const px = arch.field(Position.x)
192
+ const py = arch.field(Position.y)
193
+ const vx = arch.field(Velocity.vx)
194
+ const vy = arch.field(Velocity.vy)
195
+ for (let i = 0; i < arch.count; i++) {
196
+ px[i] += vx[i]
197
+ py[i] += vy[i]
198
+ }
199
+ })
200
+ }
201
+ }
202
+
203
+ class DeathSystem extends System {
204
+ @OnAdded(Health)
205
+ onSpawn(id: EntityId) {
206
+ console.log(`Entity ${id} spawned with ${this.em.get(id, Health.hp)} HP`)
207
+ }
208
+
209
+ @OnRemoved(Health)
210
+ onDeath(id: EntityId) {
211
+ this.em.addComponent(id, Dead)
212
+ }
213
+ }
214
+
215
+ const em = createEntityManager()
216
+ const pipeline = createSystems(em, [MovementSystem, DeathSystem])
217
+
218
+ // Game loop
219
+ em.flushHooks()
220
+ pipeline()
221
+ ```
222
+
223
+ `@OnAdded(Health, Position)` fires when an entity has **all** specified components. `@OnRemoved(Health)` fires when any specified component is removed. Hooks are buffered and deduplicated — they fire during `pipeline()` (or `sys.run()`), after `flushHooks()` collects the pending changes.
224
+
225
+ A functional API is also available:
226
+
227
+ ```ts
228
+ import { createSystem } from 'archetype-ecs'
229
+
230
+ const deathSystem = createSystem(em, (sys) => {
231
+ sys.onAdded(Health, (id) => console.log(`${id} spawned`))
232
+ sys.onRemoved(Health, (id) => console.log(`${id} died`))
233
+ })
234
+
235
+ em.flushHooks()
236
+ deathSystem()
237
+ ```
186
238
 
187
239
  ### Serialize
188
240
 
189
- ```js
241
+ ```ts
190
242
  const symbolToName = new Map([
191
243
  [Position._sym, 'Position'],
192
244
  [Velocity._sym, 'Velocity'],
@@ -200,41 +252,25 @@ const json = JSON.stringify(snapshot)
200
252
  em.deserialize(JSON.parse(json), { Position, Velocity, Health })
201
253
  ```
202
254
 
203
- Strip components, skip entities, or plug in custom serializers — see the API section below.
255
+ Supports stripping components, skipping entities, and custom serializers.
204
256
 
205
257
  ---
206
258
 
207
259
  ## TypeScript
208
260
 
209
- Every component carries its type. Field names autocomplete, wrong fields and shapes are compile errors.
261
+ Component types are inferred from their definition. Field names autocomplete, wrong fields are compile errors.
210
262
 
211
263
  ```ts
212
- // Schema is inferred — Position becomes ComponentDef<{ x: number; y: number }>
264
+ // Schema is inferred — Position becomes ComponentDef<'x' | 'y'>
213
265
  const Position = component('Position', 'f32', ['x', 'y'])
214
266
 
215
- Position.x // autocompletes to .x and .y
216
- Position.z // Property 'z' does not exist
267
+ Position.x // FieldRef — autocompletes to .x and .y
268
+ Position.z // compile error: Property 'z' does not exist
217
269
 
218
- em.get(id, Position.x) // number | undefined
219
- em.set(id, Position.z, 5) // Property 'z' does not exist
270
+ em.get(id, Position.x) // zero-alloc field access
271
+ em.set(id, Position.x, 5) // zero-alloc field write
220
272
 
221
- em.addComponent(id, Position, { x: 1, y: 2 }) // ok
222
- em.addComponent(id, Position, { x: 1 }) // Property 'y' is missing
223
-
224
- em.getComponent(id, Position) // { x: number; y: number } | undefined
225
- ```
226
-
227
- String fields are fully typed too:
228
-
229
- ```ts
230
- const Name = component('Name', 'string', ['name', 'title'])
231
-
232
- em.get(id, Name.name) // string | undefined
233
- em.set(id, Name.name, 'Hero') // ok
234
- em.set(id, Name.name, 42) // number not assignable to string
235
-
236
- em.addComponent(id, Name, { name: 'Hero', title: 'Sir' }) // ok
237
- em.addComponent(id, Name, { foo: 'bar' }) // type error
273
+ arch.field(Position.x) // Float32Array direct TypedArray access
238
274
  ```
239
275
 
240
276
  ---
@@ -249,7 +285,7 @@ Tag component — no data, used as a marker for queries.
249
285
 
250
286
  Schema component with uniform field type.
251
287
 
252
- ```js
288
+ ```ts
253
289
  const Position = component('Position', 'f32', ['x', 'y'])
254
290
  const Name = component('Name', 'string', ['name', 'title'])
255
291
  ```
@@ -258,7 +294,7 @@ const Name = component('Name', 'string', ['name', 'title'])
258
294
 
259
295
  Schema component with mixed field types.
260
296
 
261
- ```js
297
+ ```ts
262
298
  const Item = component('Item', { name: 'string', weight: 'f32', armor: 'u8' })
263
299
  ```
264
300
 
@@ -269,27 +305,51 @@ Returns an entity manager with the following methods:
269
305
  | Method | Description |
270
306
  |---|---|
271
307
  | `createEntity()` | Create an empty entity |
272
- | `createEntityWith(Comp, data, ...)` | Create entity with components no migration cost |
308
+ | `createEntityWith(Comp, data, ...)` | Create entity with components in one call |
273
309
  | `destroyEntity(id)` | Remove entity and all its components |
274
310
  | `addComponent(id, Comp, data)` | Add a component to an existing entity |
275
311
  | `removeComponent(id, Comp)` | Remove a component |
276
312
  | `hasComponent(id, Comp)` | Check if entity has a component |
277
313
  | `getComponent(id, Comp)` | Get component data as object *(allocates)* |
278
- | `get(id, Comp.field)` | Read a single field *(zero-alloc)* |
279
- | `set(id, Comp.field, value)` | Write a single field *(zero-alloc)* |
314
+ | `get(id, Comp.field)` | Read a single field |
315
+ | `set(id, Comp.field, value)` | Write a single field |
280
316
  | `query(include, exclude?)` | Get matching entity IDs |
281
317
  | `count(include, exclude?)` | Count matching entities |
282
- | `forEach(include, callback, exclude?)` | Iterate archetypes with raw TypedArray access |
318
+ | `forEach(include, callback, exclude?)` | Iterate archetypes with TypedArray access |
319
+ | `onAdd(Comp, callback)` | Register callback for component additions *(deferred)* |
320
+ | `onRemove(Comp, callback)` | Register callback for component removals *(deferred)* |
321
+ | `flushHooks()` | Collect pending add/remove events for registered hooks |
283
322
  | `serialize(symbolToName, strip?, skip?, opts?)` | Serialize world to JSON-friendly object |
284
323
  | `deserialize(data, nameToSymbol, opts?)` | Restore world from serialized data |
285
324
 
325
+ ### `System`
326
+
327
+ Base class for decorator-based systems.
328
+
329
+ | | Description |
330
+ |---|---|
331
+ | `@OnAdded(...Comps)` | Decorator — method fires when entity gains **all** specified components |
332
+ | `@OnRemoved(...Comps)` | Decorator — method fires when **any** specified component is removed |
333
+ | `tick()` | Override — called every `run()` after hook callbacks |
334
+ | `forEach(types, callback, exclude?)` | Shorthand for `this.em.forEach(...)` |
335
+ | `run()` | Fire buffered hook callbacks, then `tick()` |
336
+ | `dispose()` | Unsubscribe all hooks |
337
+
338
+ ### `createSystem(em, constructor)`
339
+
340
+ Functional alternative to class-based systems. The constructor receives a context with `onAdded`, `onRemoved`, and `forEach`, and optionally returns a tick function.
341
+
342
+ ### `createSystems(em, entries)`
343
+
344
+ Creates a pipeline from an array of class-based (`System` subclasses) and/or functional system constructors. Returns a callable that runs all systems in order, with a `dispose()` method.
345
+
286
346
  ---
287
347
 
288
348
  ## Benchmarks
289
349
 
290
350
  1M entities, Position += Velocity, 5 runs (median), Node.js:
291
351
 
292
- | | archetype-ecs | bitecs | wolf-ecs | harmony-ecs | miniplex |
352
+ | | archetype-ecs | [bitecs](https://github.com/NateTheGreatt/bitECS) | [wolf-ecs](https://github.com/EnderShadow8/wolf-ecs) | [harmony-ecs](https://github.com/3mcd/harmony-ecs) | [miniplex](https://github.com/hmans/miniplex) |
293
353
  |---|---:|---:|---:|---:|---:|
294
354
  | **Iteration** (ms/frame) | **1.7** | 2.2 | 2.2 | 1.8 | 32.5 |
295
355
  | **Entity creation** (ms) | 401 | 366 | **106** | 248 | 265 |
@@ -297,7 +357,7 @@ Returns an entity manager with the following methods:
297
357
 
298
358
  Each library runs the same test — iterate 1M entities over 500 frames:
299
359
 
300
- ```js
360
+ ```ts
301
361
  // archetype-ecs
302
362
  em.forEach([Position, Velocity], (arch) => {
303
363
  const px = arch.field(Position.x) // Float32Array, dense
@@ -311,7 +371,7 @@ em.forEach([Position, Velocity], (arch) => {
311
371
  })
312
372
  ```
313
373
 
314
- archetype-ecs has the fastest iteration — the metric that matters most for game loops. Harmony-ecs and wolf-ecs are close behind; miniplex pays for object-based storage with ~20x slower iteration.
374
+ archetype-ecs is fastest at iteration. Harmony-ecs and wolf-ecs are close; miniplex is ~20x slower due to object-based storage.
315
375
 
316
376
  Run them yourself:
317
377
 
@@ -323,7 +383,7 @@ npm run bench
323
383
 
324
384
  ## Feature comparison
325
385
 
326
- How archetype-ecs stacks up against other JS ECS libraries:
386
+ Compared against other JS ECS libraries:
327
387
 
328
388
  ### Unique to archetype-ecs
329
389
 
@@ -333,6 +393,7 @@ How archetype-ecs stacks up against other JS ECS libraries:
333
393
  | Mixed string + numeric components | ✓ | — | — | — | — |
334
394
  | `forEach` with dense TypedArray field access | ✓ | — | — | — | — |
335
395
  | Field descriptors for both per-entity and bulk access | ✓ | — | — | — | — |
396
+ | TC39 decorator system (`@OnAdded` / `@OnRemoved`) | ✓ | — | — | — | — |
336
397
  | Built-in profiler | ✓ | — | — | — | — |
337
398
 
338
399
  ### Full comparison
@@ -345,12 +406,14 @@ How archetype-ecs stacks up against other JS ECS libraries:
345
406
  | TypeScript type inference | ✓ | — | ✓ | ✓ | ✓✓ |
346
407
  | Batch entity creation | ✓ | — | — | ✓ | ✓ |
347
408
  | Zero-alloc per-entity access | ✓ | ✓ | ✓ | ✓ | — |
409
+ | System framework (class + functional) | ✓ | — | — | — | — |
410
+ | Component lifecycle hooks | ✓ | — | — | — | ✓ |
348
411
  | Relations / hierarchies | — | ✓ | — | — | — |
349
412
  | React integration | — | — | — | — | ✓ |
350
413
 
351
414
  ✓✓ = notably stronger implementation in that library.
352
415
 
353
- archetype-ecs is the only library that combines fastest iteration, string SoA storage, serialization, type safety, and a built-in profiler.
416
+ archetype-ecs is the only one that combines fast iteration, string storage, serialization, decorator-based systems, and type safety.
354
417
 
355
418
  ---
356
419
 
@@ -4,7 +4,7 @@
4
4
  import {
5
5
  createWorld, addEntity, addComponent, removeEntity, query
6
6
  } from 'bitecs';
7
- import { createEntityManager, component } from '../src/index.js';
7
+ import { createEntityManager, component } from '../dist/index.js';
8
8
 
9
9
  const COUNT = 1_000_000;
10
10
  const RUNS = 5;
@@ -2,7 +2,7 @@
2
2
  // Tests: iteration (Position += Velocity), entity creation, memory usage
3
3
  // Run with: node --expose-gc bench/multi-ecs-bench.js
4
4
 
5
- import { createEntityManager, component } from '../src/index.js';
5
+ import { createEntityManager, component } from '../dist/index.js';
6
6
 
7
7
  const COUNT = 1_000_000;
8
8
  const FRAMES = 500;
@@ -3,7 +3,7 @@
3
3
  import {
4
4
  createWorld, addEntity, addComponent, query
5
5
  } from 'bitecs';
6
- import { createEntityManager, component } from '../src/index.js';
6
+ import { createEntityManager, component } from '../dist/index.js';
7
7
 
8
8
  const COUNT = 1_000_000;
9
9
  const FRAMES = 500;
@@ -1,38 +1,34 @@
1
- // Benchmark: typed (SoA TypedArrays) vs untyped (object arrays) components
1
+ // Benchmark: forEach+field (bulk TypedArray) vs query+get/set (per-entity) vs query+getComponent (allocating)
2
2
 
3
- import { createEntityManager, component } from '../src/index.js';
3
+ import { createEntityManager, component } from '../dist/index.js';
4
4
 
5
5
  const COUNT = 1_000_000;
6
6
  const FRAMES = 200;
7
7
 
8
- // --- Typed: creation ---
9
- function benchTypedCreate() {
10
- const Pos = component('TP', 'f32', ['x', 'y']);
11
- const Vel = component('TV', 'f32', ['vx', 'vy']);
8
+ // --- forEach + field() — bulk dense TypedArray access ---
9
+ function benchForEachField() {
10
+ const Pos = component('FP', 'f32', ['x', 'y']);
11
+ const Vel = component('FV', 'f32', ['vx', 'vy']);
12
12
  const em = createEntityManager();
13
13
 
14
- const t0 = performance.now();
15
14
  for (let i = 0; i < COUNT; i++) {
16
15
  em.createEntityWith(Pos, { x: i, y: i }, Vel, { vx: 1, vy: 1 });
17
16
  }
18
- return { em, Pos, Vel, time: performance.now() - t0 };
19
- }
20
-
21
- // --- Untyped: creation ---
22
- function benchUntypedCreate() {
23
- const Pos = component('UP');
24
- const Vel = component('UV');
25
- const em = createEntityManager();
26
17
 
27
- const t0 = performance.now();
28
- for (let i = 0; i < COUNT; i++) {
29
- em.createEntityWith(Pos, { x: i, y: i }, Vel, { vx: 1, vy: 1 });
18
+ // Warmup
19
+ for (let f = 0; f < 5; f++) {
20
+ em.forEach([Pos, Vel], (arch) => {
21
+ const px = arch.field(Pos.x);
22
+ const py = arch.field(Pos.y);
23
+ const vx = arch.field(Vel.vx);
24
+ const vy = arch.field(Vel.vy);
25
+ for (let i = 0; i < arch.count; i++) {
26
+ px[i] += vx[i];
27
+ py[i] += vy[i];
28
+ }
29
+ });
30
30
  }
31
- return { em, Pos, Vel, time: performance.now() - t0 };
32
- }
33
31
 
34
- // --- Typed: iteration with forEach + field() ---
35
- function benchTypedIterate(em, Pos, Vel) {
36
32
  const t0 = performance.now();
37
33
  for (let f = 0; f < FRAMES; f++) {
38
34
  em.forEach([Pos, Vel], (arch) => {
@@ -49,19 +45,61 @@ function benchTypedIterate(em, Pos, Vel) {
49
45
  return (performance.now() - t0) / FRAMES;
50
46
  }
51
47
 
52
- // --- Untyped: iteration with forEach (object access) ---
53
- function benchUntypedIterate(em, Pos, Vel) {
48
+ // --- query + get/set per-entity field access (zero-alloc per field) ---
49
+ function benchQueryGetSet() {
50
+ const Pos = component('GP', 'f32', ['x', 'y']);
51
+ const Vel = component('GV', 'f32', ['vx', 'vy']);
52
+ const em = createEntityManager();
53
+
54
+ for (let i = 0; i < COUNT; i++) {
55
+ em.createEntityWith(Pos, { x: i, y: i }, Vel, { vx: 1, vy: 1 });
56
+ }
57
+
58
+ const ids = em.query([Pos, Vel]);
59
+
60
+ // Warmup
61
+ for (let f = 0; f < 5; f++) {
62
+ for (let i = 0; i < ids.length; i++) {
63
+ em.set(ids[i], Pos.x, em.get(ids[i], Pos.x) + em.get(ids[i], Vel.vx));
64
+ em.set(ids[i], Pos.y, em.get(ids[i], Pos.y) + em.get(ids[i], Vel.vy));
65
+ }
66
+ }
67
+
54
68
  const t0 = performance.now();
55
69
  for (let f = 0; f < FRAMES; f++) {
56
- const ids = em.query([Pos, Vel]);
70
+ for (let i = 0; i < ids.length; i++) {
71
+ em.set(ids[i], Pos.x, em.get(ids[i], Pos.x) + em.get(ids[i], Vel.vx));
72
+ em.set(ids[i], Pos.y, em.get(ids[i], Pos.y) + em.get(ids[i], Vel.vy));
73
+ }
74
+ }
75
+ return (performance.now() - t0) / FRAMES;
76
+ }
77
+
78
+ // --- query + getComponent — per-entity object allocation ---
79
+ function benchQueryGetComponent() {
80
+ const Pos = component('CP', 'f32', ['x', 'y']);
81
+ const Vel = component('CV', 'f32', ['vx', 'vy']);
82
+ const em = createEntityManager();
83
+
84
+ for (let i = 0; i < COUNT; i++) {
85
+ em.createEntityWith(Pos, { x: i, y: i }, Vel, { vx: 1, vy: 1 });
86
+ }
87
+
88
+ const ids = em.query([Pos, Vel]);
89
+
90
+ // Only 20 frames for this one — it's very slow at 1M
91
+ const frames = 20;
92
+
93
+ const t0 = performance.now();
94
+ for (let f = 0; f < frames; f++) {
57
95
  for (let i = 0; i < ids.length; i++) {
58
96
  const pos = em.getComponent(ids[i], Pos);
59
97
  const vel = em.getComponent(ids[i], Vel);
60
- pos.x += vel.vx;
61
- pos.y += vel.vy;
98
+ em.set(ids[i], Pos.x, pos.x + vel.vx);
99
+ em.set(ids[i], Pos.y, pos.y + vel.vy);
62
100
  }
63
101
  }
64
- return (performance.now() - t0) / FRAMES;
102
+ return (performance.now() - t0) / frames;
65
103
  }
66
104
 
67
105
  // --- String SoA: creation + access ---
@@ -102,65 +140,27 @@ function benchStringSoA() {
102
140
  return { createTime, forEachTime, getTime, count, count2 };
103
141
  }
104
142
 
105
- // --- String untyped: creation + access ---
106
- function benchStringUntyped() {
107
- const Name = component('SUn');
108
- const em = createEntityManager();
109
-
110
- const t0 = performance.now();
111
- for (let i = 0; i < COUNT; i++) {
112
- em.createEntityWith(Name, { name: `entity_${i}`, tag: 'npc' });
113
- }
114
- const createTime = performance.now() - t0;
115
-
116
- // getComponent access
117
- const ids = em.query([Name]);
118
- const t1 = performance.now();
119
- let count = 0;
120
- for (let f = 0; f < 50; f++) {
121
- for (let i = 0; i < ids.length; i++) {
122
- const n = em.getComponent(ids[i], Name);
123
- if (n.name.length > 5) count++;
124
- }
125
- }
126
- const accessTime = (performance.now() - t1) / 50;
127
-
128
- return { createTime, accessTime, count };
129
- }
130
-
131
143
  // --- Run ---
132
- console.log(`\n=== Typed vs Untyped: ${(COUNT / 1e6).toFixed(0)}M entities ===\n`);
133
-
134
- // Warmup
135
- benchTypedCreate();
136
- benchUntypedCreate();
144
+ console.log(`\n=== Access patterns: ${(COUNT / 1e6).toFixed(0)}M entities ===\n`);
137
145
 
138
- // Creation
139
- const typed = benchTypedCreate();
140
- const untyped = benchUntypedCreate();
146
+ console.log(`Iteration (${FRAMES} frames, Position += Velocity):`);
147
+ const forEachField = benchForEachField();
148
+ console.log(` forEach + field(): ${forEachField.toFixed(2)} ms/frame`);
141
149
 
142
- console.log(`Creation (${(COUNT / 1e6).toFixed(0)}M entities, createEntityWith):`);
143
- console.log(` typed: ${typed.time.toFixed(0)} ms`);
144
- console.log(` untyped: ${untyped.time.toFixed(0)} ms`);
145
- console.log(` ratio: ${(untyped.time / typed.time).toFixed(2)}x`);
150
+ const queryGetSet = benchQueryGetSet();
151
+ console.log(` query + get/set: ${queryGetSet.toFixed(2)} ms/frame`);
146
152
 
147
- // Iteration
148
- const typedIter = benchTypedIterate(typed.em, typed.Pos, typed.Vel);
149
- const untypedIter = benchUntypedIterate(untyped.em, untyped.Pos, untyped.Vel);
153
+ const queryGetComp = benchQueryGetComponent();
154
+ console.log(` query + getComponent: ${queryGetComp.toFixed(2)} ms/frame`);
150
155
 
151
- console.log(`\nIteration (${FRAMES} frames, Position += Velocity):`);
152
- console.log(` typed (forEach+field): ${typedIter.toFixed(2)} ms/frame`);
153
- console.log(` untyped (query+getComponent): ${untypedIter.toFixed(2)} ms/frame`);
154
- console.log(` ratio: ${(untypedIter / typedIter).toFixed(1)}x slower`);
156
+ console.log(`\n forEach vs get/set: ${(queryGetSet / forEachField).toFixed(1)}x faster`);
157
+ console.log(` forEach vs getComponent: ${(queryGetComp / forEachField).toFixed(1)}x faster`);
155
158
 
156
159
  // String components
157
160
  console.log(`\nString component (${(COUNT / 1e6).toFixed(0)}M entities, { name, tag }):`);
158
- const strSoA = benchStringSoA();
159
- const strUn = benchStringUntyped();
160
- console.log(` SoA create: ${strSoA.createTime.toFixed(0)} ms`);
161
- console.log(` untyped create: ${strUn.createTime.toFixed(0)} ms`);
162
- console.log(` SoA forEach(field): ${strSoA.forEachTime.toFixed(1)} ms/frame`);
163
- console.log(` SoA get(): ${strSoA.getTime.toFixed(1)} ms/frame`);
164
- console.log(` untyped getComponent(): ${strUn.accessTime.toFixed(1)} ms/frame`);
165
- console.log(` forEach vs getComponent: ${(strUn.accessTime / strSoA.forEachTime).toFixed(1)}x faster`);
161
+ const str = benchStringSoA();
162
+ console.log(` create: ${str.createTime.toFixed(0)} ms`);
163
+ console.log(` forEach(field): ${str.forEachTime.toFixed(1)} ms/frame`);
164
+ console.log(` get() per entity: ${str.getTime.toFixed(1)} ms/frame`);
165
+ console.log(` forEach vs get: ${(str.getTime / str.forEachTime).toFixed(1)}x faster`);
166
166
  console.log();