archetype-ecs 1.2.0 → 1.3.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/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 JavaScript. 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,15 @@ 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>
65
58
  </table>
66
59
 
67
60
  ---
@@ -71,12 +64,12 @@ Define components, spawn entities, iterate with raw TypedArrays — no allocatio
71
64
  ```ts
72
65
  import { createEntityManager, component } from 'archetype-ecs'
73
66
 
74
- // Numeric — backed by TypedArrays for cache-friendly iteration
67
+ // Numeric — stored as TypedArrays
75
68
  const Position = component('Position', 'f32', ['x', 'y'])
76
69
  const Velocity = component('Velocity', 'f32', ['vx', 'vy'])
77
70
  const Health = component('Health', { hp: 'i32', maxHp: 'i32' })
78
71
 
79
- // Strings — backed by SoA arrays, same field access API
72
+ // Strings — stored as arrays, same API
80
73
  const Name = component('Name', 'string', ['name', 'title'])
81
74
 
82
75
  // Mixed — numeric and string fields in one component
@@ -100,7 +93,7 @@ em.addComponent(player, Velocity, { vx: 0, vy: 0 })
100
93
  em.addComponent(player, Health, { hp: 100, maxHp: 100 })
101
94
  em.addComponent(player, Name, { name: 'Hero', title: 'Sir' })
102
95
 
103
- // Or all at once — no archetype migration overhead
96
+ // Or all at once
104
97
  for (let i = 0; i < 10_000; i++) {
105
98
  em.createEntityWith(
106
99
  Position, { x: Math.random() * 800, y: Math.random() * 600 },
@@ -117,7 +110,7 @@ em.destroyEntity(player)
117
110
  ### Read & write
118
111
 
119
112
  ```js
120
- // Zero allocation access any field directly
113
+ // Access a single field (doesn't allocate)
121
114
  em.get(player, Position.x) // 0
122
115
  em.get(player, Name.name) // 'Hero'
123
116
  em.set(player, Velocity.vx, 5)
@@ -131,9 +124,9 @@ em.getComponent(player, Name) // { name: 'Hero', title: 'Sir' }
131
124
 
132
125
  Two ways to work with entities in bulk. Pick the right one for the job:
133
126
 
134
- #### `forEach` — zero-alloc bulk processing
127
+ #### `forEach` — bulk processing
135
128
 
136
- Best for **systems that run every frame**. Gives you raw TypedArrays — no entity lookups, no object allocations, no cache misses.
129
+ Iterates over matching archetypes. You get the backing TypedArrays directly.
137
130
 
138
131
  ```js
139
132
  function movementSystem(dt) {
@@ -152,7 +145,7 @@ function movementSystem(dt) {
152
145
 
153
146
  #### `query` — when you need entity IDs
154
147
 
155
- Best for **event-driven logic** where you need to store, pass around, or target specific entity IDs.
148
+ Returns entity IDs for when you need to target specific entities.
156
149
 
157
150
  ```js
158
151
  // Find the closest enemy to the player
@@ -182,7 +175,7 @@ const total = em.count([Position])
182
175
  | **Use for** | Movement, physics, rendering | Damage events, UI, spawning |
183
176
  | **Runs** | Every frame | On demand |
184
177
  | **Allocates** | Nothing | `number[]` of entity IDs |
185
- | **Access** | Raw TypedArrays by field | `get` / `set` by entity ID |
178
+ | **Access** | TypedArrays by field | `get` / `set` by entity ID |
186
179
 
187
180
  ### Serialize
188
181
 
@@ -200,13 +193,13 @@ const json = JSON.stringify(snapshot)
200
193
  em.deserialize(JSON.parse(json), { Position, Velocity, Health })
201
194
  ```
202
195
 
203
- Strip components, skip entities, or plug in custom serializers — see the API section below.
196
+ Supports stripping components, skipping entities, and custom serializers.
204
197
 
205
198
  ---
206
199
 
207
200
  ## TypeScript
208
201
 
209
- Every component carries its type. Field names autocomplete, wrong fields and shapes are compile errors.
202
+ Component types are inferred from their definition. Field names autocomplete, wrong fields are compile errors.
210
203
 
211
204
  ```ts
212
205
  // Schema is inferred — Position becomes ComponentDef<{ x: number; y: number }>
@@ -269,17 +262,17 @@ Returns an entity manager with the following methods:
269
262
  | Method | Description |
270
263
  |---|---|
271
264
  | `createEntity()` | Create an empty entity |
272
- | `createEntityWith(Comp, data, ...)` | Create entity with components no migration cost |
265
+ | `createEntityWith(Comp, data, ...)` | Create entity with components in one call |
273
266
  | `destroyEntity(id)` | Remove entity and all its components |
274
267
  | `addComponent(id, Comp, data)` | Add a component to an existing entity |
275
268
  | `removeComponent(id, Comp)` | Remove a component |
276
269
  | `hasComponent(id, Comp)` | Check if entity has a component |
277
270
  | `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)* |
271
+ | `get(id, Comp.field)` | Read a single field |
272
+ | `set(id, Comp.field, value)` | Write a single field |
280
273
  | `query(include, exclude?)` | Get matching entity IDs |
281
274
  | `count(include, exclude?)` | Count matching entities |
282
- | `forEach(include, callback, exclude?)` | Iterate archetypes with raw TypedArray access |
275
+ | `forEach(include, callback, exclude?)` | Iterate archetypes with TypedArray access |
283
276
  | `serialize(symbolToName, strip?, skip?, opts?)` | Serialize world to JSON-friendly object |
284
277
  | `deserialize(data, nameToSymbol, opts?)` | Restore world from serialized data |
285
278
 
@@ -289,7 +282,7 @@ Returns an entity manager with the following methods:
289
282
 
290
283
  1M entities, Position += Velocity, 5 runs (median), Node.js:
291
284
 
292
- | | archetype-ecs | bitecs | wolf-ecs | harmony-ecs | miniplex |
285
+ | | 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
286
  |---|---:|---:|---:|---:|---:|
294
287
  | **Iteration** (ms/frame) | **1.7** | 2.2 | 2.2 | 1.8 | 32.5 |
295
288
  | **Entity creation** (ms) | 401 | 366 | **106** | 248 | 265 |
@@ -311,7 +304,7 @@ em.forEach([Position, Velocity], (arch) => {
311
304
  })
312
305
  ```
313
306
 
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.
307
+ archetype-ecs is fastest at iteration. Harmony-ecs and wolf-ecs are close; miniplex is ~20x slower due to object-based storage.
315
308
 
316
309
  Run them yourself:
317
310
 
@@ -323,7 +316,7 @@ npm run bench
323
316
 
324
317
  ## Feature comparison
325
318
 
326
- How archetype-ecs stacks up against other JS ECS libraries:
319
+ Compared against other JS ECS libraries:
327
320
 
328
321
  ### Unique to archetype-ecs
329
322
 
@@ -350,7 +343,7 @@ How archetype-ecs stacks up against other JS ECS libraries:
350
343
 
351
344
  ✓✓ = notably stronger implementation in that library.
352
345
 
353
- archetype-ecs is the only library that combines fastest iteration, string SoA storage, serialization, type safety, and a built-in profiler.
346
+ archetype-ecs is the only one that combines fast iteration, string storage, serialization, and type safety.
354
347
 
355
348
  ---
356
349
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archetype-ecs",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "Lightweight archetype-based Entity Component System",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -46,10 +46,39 @@ function soaSwap(store, idxA, idxB) {
46
46
  }
47
47
  }
48
48
 
49
+ function createSnapshotStore(schema, capacity) {
50
+ const snap = {};
51
+ for (const [field, Ctor] of Object.entries(schema)) {
52
+ snap[field] = new Ctor(capacity);
53
+ }
54
+ return snap;
55
+ }
56
+
57
+ function growSnapshotStore(snap, schema, newCapacity) {
58
+ for (const [field, Ctor] of Object.entries(schema)) {
59
+ const old = snap[field];
60
+ snap[field] = new Ctor(newCapacity);
61
+ if (Ctor === Array) {
62
+ for (let i = 0; i < old.length; i++) snap[field][i] = old[i];
63
+ } else {
64
+ snap[field].set(old);
65
+ }
66
+ }
67
+ }
68
+
49
69
  export function createEntityManager() {
50
70
  let nextId = 1;
51
71
  const allEntityIds = new Set();
52
72
 
73
+ // Change tracking (opt-in via enableTracking)
74
+ let trackFilter = 0; // bitmask — only track archetypes matching this
75
+ let createdSet = null; // Set<EntityId>
76
+ let destroyedSet = null; // Set<EntityId>
77
+
78
+ // Double-buffered snapshots: tracked archetypes get back-buffer arrays
79
+ // Game systems write to front (normal SoA arrays), flushSnapshots copies front→back
80
+ const trackedArchetypes = []; // archetypes that match trackFilter
81
+
53
82
  // Component bit registry (symbol → bit index 0..31)
54
83
  const componentBitIndex = new Map();
55
84
  let nextBitIndex = 0;
@@ -84,20 +113,29 @@ export function createEntityManager() {
84
113
  const key = computeMask(types);
85
114
  let arch = archetypes.get(key);
86
115
  if (!arch) {
116
+ const tracked = trackFilter !== 0 && (key & trackFilter) !== 0;
87
117
  arch = {
88
118
  key,
89
119
  types: new Set(types),
90
120
  entityIds: [],
91
121
  components: new Map(),
122
+ snapshots: tracked ? new Map() : null,
123
+ snapshotEntityIds: tracked ? [] : null,
124
+ snapshotCount: 0,
92
125
  entityToIndex: new Map(),
93
126
  count: 0,
94
127
  capacity: INITIAL_CAPACITY
95
128
  };
96
129
  for (const t of types) {
97
130
  const schema = componentSchemas.get(t);
98
- arch.components.set(t, schema ? createSoAStore(schema, INITIAL_CAPACITY) : null);
131
+ const store = schema ? createSoAStore(schema, INITIAL_CAPACITY) : null;
132
+ arch.components.set(t, store);
133
+ if (tracked && store) {
134
+ arch.snapshots.set(t, createSnapshotStore(schema, INITIAL_CAPACITY));
135
+ }
99
136
  }
100
137
  archetypes.set(key, arch);
138
+ if (tracked) trackedArchetypes.push(arch);
101
139
  queryCacheVersion++;
102
140
  }
103
141
  return arch;
@@ -108,7 +146,13 @@ export function createEntityManager() {
108
146
  const newCap = arch.capacity * 2;
109
147
  arch.capacity = newCap;
110
148
  for (const [type, store] of arch.components) {
111
- if (store) growSoAStore(store, newCap);
149
+ if (store) {
150
+ growSoAStore(store, newCap);
151
+ if (arch.snapshots) {
152
+ const snap = arch.snapshots.get(type);
153
+ if (snap) growSnapshotStore(snap, store._schema, newCap);
154
+ }
155
+ }
112
156
  }
113
157
  }
114
158
 
@@ -182,6 +226,7 @@ export function createEntityManager() {
182
226
  destroyEntity(id) {
183
227
  const arch = entityArchetype.get(id);
184
228
  if (arch) {
229
+ if (destroyedSet && (arch.key & trackFilter)) destroyedSet.add(id);
185
230
  removeFromArchetype(arch, id);
186
231
  }
187
232
  allEntityIds.delete(id);
@@ -224,6 +269,9 @@ export function createEntityManager() {
224
269
  const arch = entityArchetype.get(entityId);
225
270
  if (!arch || !arch.types.has(type)) return;
226
271
 
272
+ // If entity is leaving a tracked archetype, treat as destroyed
273
+ if (destroyedSet && (arch.key & trackFilter)) destroyedSet.add(entityId);
274
+
227
275
  if (arch.types.size === 1) {
228
276
  removeFromArchetype(arch, entityId);
229
277
  return;
@@ -309,6 +357,7 @@ export function createEntityManager() {
309
357
  const arch = getOrCreateArchetype(types);
310
358
  addToArchetype(arch, id, map);
311
359
 
360
+ if (createdSet && (arch.key & trackFilter)) createdSet.add(id);
312
361
  return id;
313
362
  },
314
363
 
@@ -326,20 +375,87 @@ export function createEntityManager() {
326
375
  for (let a = 0; a < matching.length; a++) {
327
376
  const arch = matching[a];
328
377
  if (arch.count === 0) continue;
378
+ const snaps = arch.snapshots;
329
379
  const view = {
380
+ id: arch.key,
330
381
  entityIds: arch.entityIds,
331
382
  count: arch.count,
383
+ snapshotEntityIds: arch.snapshotEntityIds,
384
+ snapshotCount: arch.snapshotCount,
332
385
  field(ref) {
333
386
  const sym = ref._sym || ref;
334
387
  const store = arch.components.get(sym);
335
388
  if (!store) return undefined;
336
389
  return store[ref._field];
390
+ },
391
+ snapshot(ref) {
392
+ if (!snaps) return undefined;
393
+ const sym = ref._sym || ref;
394
+ const snap = snaps.get(sym);
395
+ if (!snap) return undefined;
396
+ return snap[ref._field];
337
397
  }
338
398
  };
339
399
  callback(view);
340
400
  }
341
401
  },
342
402
 
403
+ enableTracking(filterComponent) {
404
+ trackFilter = 1 << getBit(filterComponent);
405
+ createdSet = new Set();
406
+ destroyedSet = new Set();
407
+ // Retroactively add snapshots to existing matching archetypes
408
+ for (const arch of archetypes.values()) {
409
+ if ((arch.key & trackFilter) !== 0 && !arch.snapshots) {
410
+ arch.snapshots = new Map();
411
+ arch.snapshotEntityIds = [];
412
+ arch.snapshotCount = 0;
413
+ for (const [t, store] of arch.components) {
414
+ if (store) {
415
+ arch.snapshots.set(t, createSnapshotStore(store._schema, arch.capacity));
416
+ }
417
+ }
418
+ trackedArchetypes.push(arch);
419
+ }
420
+ }
421
+ },
422
+
423
+ flushChanges() {
424
+ const result = { created: createdSet, destroyed: destroyedSet };
425
+ createdSet = new Set();
426
+ destroyedSet = new Set();
427
+ return result;
428
+ },
429
+
430
+ flushSnapshots() {
431
+ for (let a = 0; a < trackedArchetypes.length; a++) {
432
+ const arch = trackedArchetypes[a];
433
+ const count = arch.count;
434
+ // Copy entityIds
435
+ const eids = arch.entityIds;
436
+ const snapEids = arch.snapshotEntityIds;
437
+ for (let i = 0; i < count; i++) snapEids[i] = eids[i];
438
+ arch.snapshotCount = count;
439
+ // Copy all field arrays via .set() (one memcpy per field)
440
+ for (const [type, store] of arch.components) {
441
+ if (!store) continue;
442
+ const snap = arch.snapshots.get(type);
443
+ if (!snap) continue;
444
+ for (const field in store._schema) {
445
+ const src = store[field];
446
+ const dst = snap[field];
447
+ if (src.set) {
448
+ // TypedArray — use .set() for memcpy, only copy active region
449
+ dst.set(src.subarray(0, count));
450
+ } else {
451
+ // Regular Array (string fields)
452
+ for (let i = 0; i < count; i++) dst[i] = src[i];
453
+ }
454
+ }
455
+ }
456
+ }
457
+ },
458
+
343
459
  serialize(symbolToName, stripComponents = [], skipEntitiesWith = [], { serializers } = {}) {
344
460
  const stripSymbols = new Set(stripComponents.map(toSym));
345
461
  const skipSymbols = new Set(skipEntitiesWith.map(toSym));
package/src/index.d.ts CHANGED
@@ -41,9 +41,13 @@ type TypedArray = Float32Array | Float64Array | Int8Array | Int16Array | Int32Ar
41
41
 
42
42
  // === ArchetypeView (forEach callback) ===
43
43
  export interface ArchetypeView {
44
+ readonly id: number;
44
45
  readonly entityIds: EntityId[];
45
46
  readonly count: number;
47
+ readonly snapshotEntityIds: EntityId[] | null;
48
+ readonly snapshotCount: number;
46
49
  field(ref: FieldRef<any>): TypedArray | unknown[] | undefined;
50
+ snapshot(ref: FieldRef<any>): TypedArray | unknown[] | undefined;
47
51
  }
48
52
 
49
53
  // === Serialize/Deserialize ===
@@ -76,6 +80,9 @@ export interface EntityManager {
76
80
  createEntityWith(...args: unknown[]): EntityId;
77
81
  count(include: ComponentDef[], exclude?: ComponentDef[]): number;
78
82
  forEach(include: ComponentDef[], callback: (view: ArchetypeView) => void, exclude?: ComponentDef[]): void;
83
+ enableTracking(filterComponent: ComponentDef): void;
84
+ flushChanges(): { created: Set<EntityId>; destroyed: Set<EntityId> };
85
+ flushSnapshots(): void;
79
86
  serialize(
80
87
  symbolToName: Map<symbol, string>,
81
88
  stripComponents?: ComponentDef[],
@@ -1,32 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(node /mnt/d/Projects/ECS/bench/vs-bitecs.js:*)",
5
- "Bash(node:*)",
6
- "Bash(npm test:*)",
7
- "Bash(git add:*)",
8
- "Bash(git commit -m \"$\\(cat <<''EOF''\nAdd getField/setField for zero-allocation field access with TS generics\n\ngetField and setField read/write individual component fields directly\nfrom TypedArrays without reconstructing an object. TypeScript generics\non component\\(\\) flow through to autocomplete field names and catch\ninvalid fields at compile time.\n\nCo-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>\nEOF\n\\)\")",
9
- "Bash(git push:*)",
10
- "Bash(git commit -m \"$\\(cat <<''EOF''\nAdd compile-time type tests for TS generics validation\n\ntests/types.ts validates that component\\(\\) schema inference flows\nthrough getField, setField, addComponent, and forEach field\\(\\) with\n@ts-expect-error assertions for invalid field names. npm test now\nruns both runtime tests and tsc --noEmit.\n\nCo-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>\nEOF\n\\)\")",
11
- "Bash(git -C /mnt/d/Projects/ECS status)",
12
- "Bash(git -C /mnt/d/Projects/ECS diff)",
13
- "Bash(git -C /mnt/d/Projects/ECS log --oneline -5)",
14
- "Bash(git -C /mnt/d/Projects/ECS add bench/typed-vs-bitecs-1m.js src/ComponentRegistry.js src/EntityManager.js src/index.d.ts src/index.js tests/EntityManager.test.js tests/types.ts)",
15
- "Bash(git -C /mnt/d/Projects/ECS commit -m \"$\\(cat <<''EOF''\nAdd field descriptor API: Position.x instead of string literals\n\nComponent objects now have field descriptors as properties, enabling\nem.get\\(id, Position.x\\) / em.set\\(id, Position.x, 5\\) for zero-alloc\nfield access and arch.field\\(Position.x\\) in forEach hot loops.\n\n- component\\(\\) supports short form: component\\(''Pos'', ''f32'', [''x'',''y'']\\)\n- toSym\\(\\) helper normalizes component objects to underlying symbols\n- Updated TS types: ComponentDef<T> with FieldRef descriptors\n- All tests and benchmarks updated to new API\n\nCo-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>\nEOF\n\\)\")",
16
- "Bash(wc:*)",
17
- "Bash(git commit:*)",
18
- "WebSearch",
19
- "WebFetch(domain:github.com)",
20
- "WebFetch(domain:www.webgamedev.com)",
21
- "WebFetch(domain:3mcd.github.io)",
22
- "WebFetch(domain:www.npmjs.com)",
23
- "WebFetch(domain:raw.githubusercontent.com)",
24
- "Bash(npm view:*)",
25
- "Bash(npm pack:*)",
26
- "Bash(curl:*)",
27
- "Bash(npm install:*)",
28
- "Bash(npm run bench:*)",
29
- "Bash(FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch:*)"
30
- ]
31
- }
32
- }