From faa2adda4236829479ec4d39b6a68aa295f029ba Mon Sep 17 00:00:00 2001 From: Jake Klassen Date: Fri, 19 Dec 2025 06:50:30 -0500 Subject: [PATCH 1/9] perf phase 1 --- .gitignore | 1 + benchmark.md | 22 ++ hierarchy-ideas.md | 290 ++++++++++++++++++ .../shmup/enemy/determine-pickable-enemies.ts | 4 +- packages/objecs/src/archetype.test.ts | 19 +- packages/objecs/src/archetype.ts | 24 +- packages/objecs/src/world.test.ts | 54 ++-- packages/objecs/src/world.ts | 139 ++++++++- performance-plan.md | 154 ++++++++++ 9 files changed, 654 insertions(+), 53 deletions(-) create mode 100644 benchmark.md create mode 100644 hierarchy-ideas.md create mode 100644 performance-plan.md diff --git a/.gitignore b/.gitignore index bbd4dde..c40cd4e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules dist coverage +tmp *.log .vscode/* !.vscode/launch.json diff --git a/benchmark.md b/benchmark.md new file mode 100644 index 0000000..9566d88 --- /dev/null +++ b/benchmark.md @@ -0,0 +1,22 @@ + +> ecs-benchmark@ start /home/jakeklassen/code/packages/objecs/packages/ecs-benchmark +> node src/bench.js objecs miniplex + +objecs + packed_5 139,836 op/s + simple_iter 80,937 op/s + frag_iter 44,291 op/s + entity_cycle 7,556 op/s + add_remove 21,209 op/s + +miniplex + packed_5 180,113 op/s + simple_iter 178,320 op/s + frag_iter 50,361 op/s + entity_cycle 2,176 op/s + add_remove 2,100 op/s + +| op/s | packed_5 | simple_iter | frag_iter | entity_cycle | add_remove | +| ---- | --: |--: |--: |--: |--: | +| objecs | 139,836 | 80,937 | 44,291 | 7,556 | 21,209 | +| miniplex | 180,113 | 178,320 | 50,361 | 2,176 | 2,100 | diff --git a/hierarchy-ideas.md b/hierarchy-ideas.md new file mode 100644 index 0000000..02a1ef9 --- /dev/null +++ b/hierarchy-ideas.md @@ -0,0 +1,290 @@ +# Hierarchy and Scene Graph Ideas for objECS + +This document captures ideas for implementing parent-child hierarchy and scene graph functionality in objECS, inspired by [Wicked Engine's ECS article](https://wickedengine.net/2019/09/entity-component-system/). + +## Current State + +The shmup demo uses a simple pattern for hierarchy: + +```typescript +// Entity type has parent reference +parent?: SetRequired; +localTransform?: Transform; + +// local-transform-system.ts manually updates child positions +entity.transform.position.x = + entity.parent.transform.position.x + entity.localTransform.position.x; +``` + +This works but doesn't handle: +- Deep hierarchies (grandchildren) +- Processing order (parent must update before child) +- Rotation/scale inheritance +- Attach/detach operations + +## Wicked Engine's Approach + +Key concepts from the C++ implementation: + +1. **HierarchyComponent** - stores parent entity + parent's inverse world matrix at attach time +2. **Ordering guarantee** - parents are always processed before children (topological sort) +3. **Attach/Detach operations** - handle matrix math and maintain ordering +4. **Two transforms** - local (relative to parent) and world (absolute) + +## Challenges for objECS + +| Wicked Engine | objECS | +|---------------|--------| +| Integer entity IDs | Object references | +| Ordered arrays per component | Unordered Sets | +| Component managers with sorting | Simple archetypes | + +## Potential Approaches + +### Approach 1: Depth-based Sorting (Pattern-based) + +Add a `hierarchyDepth` to entities and sort before processing. This is more of a "pattern" than a native feature - users implement it in their entity types and systems. + +```typescript +type Entity = { + transform?: Transform; + localTransform?: Transform; // Position relative to parent + parent?: Entity; + hierarchyDepth?: number; // 0 = root, 1 = child of root, etc. + children?: Set; // Optional: for fast traversal +}; + +function hierarchySystemFactory(world: World) { + const hierarchical = world.archetype("transform", "localTransform", "parent"); + + return function hierarchySystem() { + // Sort by depth to ensure parents update before children + const sorted = [...hierarchical.entities].sort( + (a, b) => (a.hierarchyDepth ?? 0) - (b.hierarchyDepth ?? 0) + ); + + for (const entity of sorted) { + const parent = entity.parent!; + + // Compose local transform with parent's world transform + entity.transform.position.x = + parent.transform!.position.x + entity.localTransform.position.x; + entity.transform.position.y = + parent.transform!.position.y + entity.localTransform.position.y; + // ... rotation, scale would need matrix math + } + }; +} +``` + +With utility functions: + +```typescript +function attachToParent( + world: World, + child: Entity, + parent: Entity +) { + const parentDepth = parent.hierarchyDepth ?? 0; + + world.addEntityComponents(child, "parent", parent); + world.addEntityComponents(child, "hierarchyDepth", parentDepth + 1); + + // Store current world position as local offset + if (child.transform && parent.transform) { + world.addEntityComponents(child, "localTransform", { + position: { + x: child.transform.position.x - parent.transform.position.x, + y: child.transform.position.y - parent.transform.position.y, + }, + rotation: child.transform.rotation - parent.transform.rotation, + scale: { ...child.transform.scale }, + }); + } + + // Track children for fast detach/reparenting + parent.children ??= new Set(); + parent.children.add(child); +} + +function detachFromParent(world: World, child: Entity) { + const parent = child.parent; + if (!parent) return; + + parent.children?.delete(child); + world.removeEntityComponents(child, "parent", "localTransform", "hierarchyDepth"); +} +``` + +**Pros:** +- Simple, fits objECS philosophy +- No new abstractions needed +- Users can customize behavior + +**Cons:** +- Sorting cost O(n log n) per frame +- Manual bookkeeping required +- Easy to forget depth updates + +--- + +### Approach 2: SceneGraph Class (Structured) + +A dedicated hierarchy manager that works alongside the ECS: + +```typescript +class SceneGraph { + #roots = new Set(); + #parentMap = new Map(); + #childrenMap = new Map>(); + #depthMap = new Map(); + + attach(child: Entity, parent: Entity) { + this.detach(child); // Remove from current parent if any + + this.#parentMap.set(child, parent); + + const children = this.#childrenMap.get(parent) ?? new Set(); + children.add(child); + this.#childrenMap.set(parent, children); + + this.#updateDepths(child, this.getDepth(parent) + 1); + this.#roots.delete(child); + } + + detach(entity: Entity) { + const parent = this.#parentMap.get(entity); + if (parent) { + this.#childrenMap.get(parent)?.delete(entity); + this.#parentMap.delete(entity); + this.#roots.add(entity); + this.#updateDepths(entity, 0); + } + } + + getParent(entity: Entity): Entity | undefined { + return this.#parentMap.get(entity); + } + + getChildren(entity: Entity): ReadonlySet { + return this.#childrenMap.get(entity) ?? new Set(); + } + + getDepth(entity: Entity): number { + return this.#depthMap.get(entity) ?? 0; + } + + // Iterate in topological order (parents before children) + *traverse(): Generator { + const visited = new Set(); + + function* visit(entity: Entity, graph: SceneGraph): Generator { + if (visited.has(entity)) return; + visited.add(entity); + + yield entity; + + for (const child of graph.getChildren(entity)) { + yield* visit(child, graph); + } + } + + for (const root of this.#roots) { + yield* visit(root, this); + } + } + + #updateDepths(entity: Entity, depth: number) { + this.#depthMap.set(entity, depth); + for (const child of this.#childrenMap.get(entity) ?? []) { + this.#updateDepths(child, depth + 1); + } + } +} +``` + +Usage: + +```typescript +const world = new World(); +const sceneGraph = new SceneGraph(); + +const player = world.createEntity({ transform: {...} }); +const thruster = world.createEntity({ transform: {...}, localTransform: {...} }); + +sceneGraph.attach(thruster, player); + +// In system - iterate in correct order +function hierarchySystem() { + for (const entity of sceneGraph.traverse()) { + const parent = sceneGraph.getParent(entity); + if (parent && entity.localTransform && entity.transform && parent.transform) { + // Update world transform from parent + local + } + } +} +``` + +**Pros:** +- Clean separation of concerns +- Guarantees correct traversal order +- Centralized hierarchy management +- Could be published as `objecs-hierarchy` package + +**Cons:** +- Additional data structure to maintain +- Need to sync with entity lifecycle (delete entity = detach) +- Slight indirection + +--- + +### Approach 3: Matrix-based Transforms (Full Scene Graph) + +For proper rotation/scale inheritance, matrix math is needed: + +```typescript +type Transform = { + position: Vector2d; + rotation: number; + scale: Vector2d; + localMatrix?: Mat3; // Computed from position/rotation/scale + worldMatrix?: Mat3; // Cached world matrix (local * parent.world) +}; + +function composeTransform(local: Transform, parentWorld: Mat3): Mat3 { + const localMatrix = Mat3.fromTRS(local.position, local.rotation, local.scale); + return Mat3.multiply(parentWorld, localMatrix); +} + +function decomposeMatrix(matrix: Mat3): { position: Vector2d; rotation: number; scale: Vector2d } { + // Extract position, rotation, scale from matrix +} +``` + +This would require a small math library (or use gl-matrix). + +--- + +## Recommendations + +1. **Start with Approach 1** for simple cases (position-only parenting) +2. **Evolve to Approach 2** if hierarchy becomes central to the game +3. **Add Approach 3** only if rotation/scale inheritance is needed + +The SceneGraph class (Approach 2) could potentially be: +- A separate package (`objecs-hierarchy`) +- Built into objECS core with opt-in usage +- Documented as a recommended pattern + +## Open Questions + +- Should SceneGraph automatically sync with World entity deletions? +- Should transforms be split into `localTransform` and `worldTransform` always? +- How to handle dirty flags / caching for performance? +- Should hierarchy be query-able via archetypes? (e.g., `world.archetype("parent")`) + +## References + +- [Wicked Engine ECS](https://wickedengine.net/2019/09/entity-component-system/) +- [Unity's Transform hierarchy](https://docs.unity3d.com/Manual/class-Transform.html) +- [Bevy's Parent/Children components](https://bevyengine.org/learn/book/getting-started/hierarchy/) diff --git a/packages/examples/src/demos/shmup/enemy/determine-pickable-enemies.ts b/packages/examples/src/demos/shmup/enemy/determine-pickable-enemies.ts index 5346c82..f6be0d3 100644 --- a/packages/examples/src/demos/shmup/enemy/determine-pickable-enemies.ts +++ b/packages/examples/src/demos/shmup/enemy/determine-pickable-enemies.ts @@ -4,8 +4,8 @@ import { Entity } from "../entity.ts"; export function determinePickableEnemies< T extends SetRequired, ->(entities: ReadonlySet) { +>(entities: Iterable) { return sortEntitiesByPosition( - Array.from(entities).filter((entity) => entity.enemyState === "protect"), + [...entities].filter((entity) => entity.enemyState === "protect"), ); } diff --git a/packages/objecs/src/archetype.test.ts b/packages/objecs/src/archetype.test.ts index 6f936da..5c99fde 100644 --- a/packages/objecs/src/archetype.test.ts +++ b/packages/objecs/src/archetype.test.ts @@ -1,5 +1,10 @@ import { describe, expect, expectTypeOf, it } from "vitest"; -import { SafeEntity, World } from "./world.js"; +import { + EntityCollection, + ReadonlyEntityCollection, + SafeEntity, + World, +} from "./world.js"; import { Archetype } from "./archetype.js"; type Entity = { @@ -27,7 +32,7 @@ describe("Archetype", () => { const world = new World(); const archetype = new Archetype({ world, - entities: new Set(), + entities: new EntityCollection(), components: ["color"], }); @@ -36,7 +41,7 @@ describe("Archetype", () => { expect(archetype.entities).toBeInstanceOf(Object); expectTypeOf(archetype.entities).toEqualTypeOf< - ReadonlySet> + ReadonlyEntityCollection> >(); }); }); @@ -46,7 +51,7 @@ describe("Archetype", () => { const world = new World(); const archetype = new Archetype({ world, - entities: new Set(), + entities: new EntityCollection(), components: ["color"], }); @@ -59,7 +64,7 @@ describe("Archetype", () => { const world = new World(); const archetype = new Archetype({ world, - entities: new Set(), + entities: new EntityCollection(), components: ["color"], without: ["tagPlayer"], }); @@ -101,7 +106,9 @@ describe("Archetype", () => { expect(world.archetypes.size).toBe(2); expectTypeOf(nonPlayerRenderables.entities).toExtend< - ReadonlySet> + ReadonlyEntityCollection< + SafeEntity + > >(); expect(nonPlayerRenderables.entities.size).toBe(1); expect(nonPlayerRenderables.entities.has(entity)).toBe(false); diff --git a/packages/objecs/src/archetype.ts b/packages/objecs/src/archetype.ts index d71ab2a..451e9c2 100644 --- a/packages/objecs/src/archetype.ts +++ b/packages/objecs/src/archetype.ts @@ -1,5 +1,5 @@ import { JsonObject } from "type-fest"; -import { World } from "./world.js"; +import { EntityCollection, ReadonlyEntityCollection, World } from "./world.js"; type SafeEntity< Entity extends JsonObject, @@ -15,7 +15,7 @@ export class Archetype< Entity extends JsonObject, Components extends Array, > { - #entities = new Set>(); + #entities: EntityCollection>; #components: Components; #excluding?: Array>; #world: World; @@ -27,12 +27,14 @@ export class Archetype< without, }: { world: World; - entities: Set; + entities: EntityCollection; components: Components; without?: Array>; }) { this.#world = world; - this.#entities = entities as Set>; + this.#entities = entities as EntityCollection< + SafeEntity + >; this.#components = components; this.#excluding = without; @@ -40,7 +42,9 @@ export class Archetype< world.archetypes.add(this as any); } - public get entities(): ReadonlySet> { + public get entities(): ReadonlyEntityCollection< + SafeEntity + > { return this.#entities; } @@ -75,7 +79,7 @@ export class Archetype< if (this.matches(entity)) { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - this.#entities.add(entity as any); + this.#entities._add(entity as any); } return this; @@ -83,13 +87,13 @@ export class Archetype< public removeEntity(entity: Entity): this { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - this.#entities.delete(entity as any); + this.#entities._remove(entity as any); return this; } clearEntities() { - this.#entities.clear(); + this.#entities._clear(); } /** @@ -107,7 +111,7 @@ export class Archetype< >, Array> > { - const entities = new Set< + const entities = new EntityCollection< SafeEntity< Omit, Exclude @@ -123,7 +127,7 @@ export class Archetype< continue; } - entities.add(entity); + entities._add(entity); } const archetype = new Archetype< diff --git a/packages/objecs/src/world.test.ts b/packages/objecs/src/world.test.ts index 115860a..59cd81e 100644 --- a/packages/objecs/src/world.test.ts +++ b/packages/objecs/src/world.test.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it } from "vitest"; -import { World } from "./world.js"; +import { ReadonlyEntityCollection, World } from "./world.js"; type Entity = { color?: string; @@ -29,7 +29,9 @@ describe("World", () => { world.createEntity({ color: "blue" }); expect(world.entities).toBeInstanceOf(Object); - expectTypeOf(world.entities).toEqualTypeOf>(); + expectTypeOf(world.entities).toEqualTypeOf< + ReadonlyEntityCollection + >(); }); }); @@ -162,9 +164,11 @@ describe("World", () => { const testTransform = { position: { x: 0, y: 0 } }; world.addEntityComponents(entity, "transform", testTransform); - expect(world.archetype("transform").entities).toEqual(new Set([entity])); + const transformArchetype = world.archetype("transform"); + expect(transformArchetype.entities.size).toBe(1); + expect(transformArchetype.entities.has(entity)).toBe(true); - expect(world.archetype("color").entities).toEqual(new Set()); + expect(world.archetype("color").entities.size).toBe(0); }); it("should be updated correctly via removeEntityComponents()", () => { @@ -172,18 +176,19 @@ describe("World", () => { const entity = world.createEntity(); const moving = world.archetype("transform", "velocity"); - expect(moving.entities).toEqual(new Set()); + expect(moving.entities.size).toBe(0); world.addEntityComponents(entity, "transform", { position: { x: 0, y: 0 }, }); world.addEntityComponents(entity, "velocity", { x: 10, y: 10 }); - expect(moving.entities).toEqual(new Set([entity])); + expect(moving.entities.size).toBe(1); + expect(moving.entities.has(entity)).toBe(true); world.removeEntityComponents(entity, "transform"); - expect(moving.entities).toEqual(new Set()); + expect(moving.entities.size).toBe(0); }); it("should not return an archetype on partial component match", () => { @@ -237,32 +242,25 @@ describe("World", () => { return () => { if (count === 0) { - const iterator = renderables.entities.values(); - - { - const entity = iterator.next().value; - expect(entity?.color).toBe("red"); - expect(entity?.transform).toEqual({ - position: { x: 0, y: 0 }, - }); - } - - { - const entity = iterator.next().value; - expect(entity?.color).toBe("blue"); - expect(entity?.transform).toEqual({ - position: { x: 1, y: 1 }, - }); - } + const entities = [...renderables.entities]; + + expect(entities[0]?.color).toBe("red"); + expect(entities[0]?.transform).toEqual({ + position: { x: 0, y: 0 }, + }); + + expect(entities[1]?.color).toBe("blue"); + expect(entities[1]?.transform).toEqual({ + position: { x: 1, y: 1 }, + }); count++; } else { expect(renderables.entities.size).toBe(1); - const iterator = renderables.entities.values(); - const entity = iterator.next().value; - expect(entity?.color).toBe("blue"); - expect(entity?.transform).toEqual({ + const entities = [...renderables.entities]; + expect(entities[0]?.color).toBe("blue"); + expect(entities[0]?.transform).toEqual({ position: { x: 1, y: 1 }, }); } diff --git a/packages/objecs/src/world.ts b/packages/objecs/src/world.ts index bb15233..88042a4 100644 --- a/packages/objecs/src/world.ts +++ b/packages/objecs/src/world.ts @@ -6,23 +6,148 @@ export type SafeEntity< Components extends keyof Entity, > = Entity & Required>; +/** + * An iterable collection of entities optimized for fast iteration. + * Provides Set-like methods but backed by an array for performance. + */ +export interface ReadonlyEntityCollection extends Iterable { + readonly size: number; + has(value: T): boolean; + forEach( + callbackfn: (value: T, value2: T, set: ReadonlyEntityCollection) => void, + thisArg?: unknown, + ): void; + entries(): IterableIterator<[T, T]>; + keys(): IterableIterator; + values(): IterableIterator; +} + +/** + * A collection backed by an array for fast iteration. + * Uses a Map for O(1) has() lookups. + */ +export class EntityCollection implements ReadonlyEntityCollection { + readonly #entities: T[] = []; + readonly #indices = new Map(); + + get size(): number { + return this.#entities.length; + } + + has(entity: T): boolean { + return this.#indices.has(entity); + } + + /** + * Add an entity. Returns true if added, false if already present. + * @internal + */ + _add(entity: T): boolean { + if (this.#indices.has(entity)) { + return false; + } + this.#indices.set(entity, this.#entities.length); + this.#entities.push(entity); + return true; + } + + /** + * Remove an entity using swap-and-pop for O(1) removal. + * Returns true if removed, false if not present. + * @internal + */ + _remove(entity: T): boolean { + const index = this.#indices.get(entity); + if (index === undefined) { + return false; + } + + this.#indices.delete(entity); + const lastIndex = this.#entities.length - 1; + + if (index !== lastIndex) { + // Swap with last element + const lastEntity = this.#entities[lastIndex]; + this.#entities[index] = lastEntity; + this.#indices.set(lastEntity, index); + } + + this.#entities.pop(); + return true; + } + + /** + * Clear all entities. + * @internal + */ + _clear(): void { + this.#entities.length = 0; + this.#indices.clear(); + } + + // Use native array iteration for best performance + [Symbol.iterator](): IterableIterator { + return this.#entities[Symbol.iterator](); + } + + entries(): IterableIterator<[T, T]> { + const entities = this.#entities; + let index = 0; + const length = entities.length; + + return { + next(): IteratorResult<[T, T]> { + if (index < length) { + const entity = entities[index++]; + return { value: [entity, entity], done: false }; + } + return { value: undefined, done: true } as IteratorResult<[T, T]>; + }, + [Symbol.iterator]() { + return this; + }, + }; + } + + keys(): IterableIterator { + return this.#entities[Symbol.iterator](); + } + + values(): IterableIterator { + return this.#entities[Symbol.iterator](); + } + + forEach( + callbackfn: ( + value: T, + value2: T, + set: ReadonlyEntityCollection, + ) => void, + thisArg?: unknown, + ): void { + for (const entity of this.#entities) { + callbackfn.call(thisArg, entity, entity, this); + } + } +} + /** * Container for Entities */ export class World { #archetypes = new Set>>(); - #entities = new Set(); + #entities = new EntityCollection(); public get archetypes(): Set>> { return this.#archetypes; } - public get entities(): ReadonlySet { + public get entities(): ReadonlyEntityCollection { return this.#entities; } public clearEntities() { - this.#entities.clear(); + this.#entities._clear(); for (const archetype of this.#archetypes) { archetype.clearEntities(); @@ -35,7 +160,7 @@ export class World { SafeEntity, typeof components > { - const entities = new Set(); + const entities = new EntityCollection(); for (const entity of this.#entities) { const matchesArchetype = components.every((component) => { @@ -43,7 +168,7 @@ export class World { }); if (matchesArchetype) { - entities.add(entity); + entities._add(entity); } } @@ -73,7 +198,7 @@ export class World { public createEntity(entity?: T) { const _entity = entity ?? ({} as T); - this.#entities.add(_entity); + this.#entities._add(_entity); for (const archetype of this.#archetypes) { archetype.addEntity(_entity); @@ -87,7 +212,7 @@ export class World { archetype.removeEntity(entity); } - return this.#entities.delete(entity); + return this.#entities._remove(entity); } public addEntityComponents( diff --git a/performance-plan.md b/performance-plan.md new file mode 100644 index 0000000..45bd6db --- /dev/null +++ b/performance-plan.md @@ -0,0 +1,154 @@ +# objecs Performance Improvement Plan + +## Benchmark Results (Baseline) + +``` +> ecs-benchmark@ start +> node src/bench.js objecs miniplex + +objecs + packed_5 139,836 op/s + simple_iter 80,937 op/s + frag_iter 44,291 op/s + entity_cycle 7,556 op/s + add_remove 21,209 op/s + +miniplex + packed_5 180,113 op/s + simple_iter 178,320 op/s + frag_iter 50,361 op/s + entity_cycle 2,176 op/s + add_remove 2,100 op/s +``` + +| Benchmark | objecs | miniplex | Winner | Gap | +|-----------|--------|----------|--------|-----| +| packed_5 | 139,836 | 180,113 | miniplex | **-22%** | +| simple_iter | 80,937 | 178,320 | miniplex | **-55%** | +| frag_iter | 44,291 | 50,361 | miniplex | -12% | +| entity_cycle | 7,556 | 2,176 | **objecs** | +247% | +| add_remove | 21,209 | 2,100 | **objecs** | +910% | + +## Key Architectural Differences + +| Aspect | objecs | miniplex | +|--------|--------|----------| +| Entity storage | `Set` | `Array + Map` | +| Removal | `Set.delete()` | Swap-and-pop (O(1)) | +| Iterator | Set's default | Custom, reuses result object | +| Query caching | None (new each call) | Cached by config key | +| Component check | `component in entity` | `entity[component] !== undefined` | +| Events | None | onEntityAdded/Removed (causes overhead) | + +## Analysis + +### Why miniplex wins iteration benchmarks: +1. **Array iteration is ~2x faster than Set iteration** in V8 +2. **Custom iterator reuses result object** (avoids GC pressure) +3. **Better cache locality** with contiguous array memory + +### Why objecs wins mutation benchmarks: +1. **No event emission overhead** (miniplex emits events on every add/remove) +2. **Simpler removeComponent** (miniplex copies entity to create "future" state) +3. **Leaner code paths** for add/remove operations + +## Refactor Guidelines + +1. **Cannot sacrifice DX or type safety** - maintain the current API and TypeScript experience +2. **Validate with benchmarks**: `pnpm --filter ecs-benchmark start objecs miniplex` +3. **Tests must pass**: `pnpm --filter objecs test --run` + +## Plan of Attack + +### Phase 1: Switch to Array + Map (Highest Impact) + +**Goal:** Close the 55% gap in `simple_iter` + +1. Replace `Set` with `Array` for storage +2. Add `Map` for O(1) index lookups +3. Implement swap-and-pop removal to maintain O(1) deletion +4. Create optimized custom iterator that reuses result objects + +**Expected impact:** 40-60% improvement in iteration benchmarks + +### Phase 2: Query/Archetype Caching + +**Goal:** Reduce redundant archetype creation overhead + +1. Cache archetypes by component signature (sorted, stringified) +2. Return existing archetype if query already exists +3. This matches miniplex's behavior + +**Expected impact:** Reduced memory allocation, faster repeated queries + +### Phase 3: Micro-optimizations + +1. Change `component in entity` → `entity[component] !== undefined` +2. Inline hot-path operations where possible +3. Avoid creating closures in frequently-called methods + +### Phase 4: Maintain Mutation Advantage + +Ensure our changes don't regress entity_cycle and add_remove performance: +- Keep event-free design +- Avoid copying entities for "future" state checks + +## Risk Assessment + +| Change | Risk | Mitigation | +|--------|------|------------| +| Array + Map | Medium - API change for iteration | Keep `ReadonlySet` facade or expose array | +| Swap-and-pop | Low - internal detail | Entity order becomes undefined (usually fine for ECS) | +| Query caching | Low | Use weak references if memory is concern | + +## Progress Log + +### Phase 1 Results ✅ + +Converted from `Set` to `EntityCollection` (Array + Map) with native array iteration delegation. + +**Before (baseline with Set):** +``` +objecs + packed_5 139,836 op/s + simple_iter 80,937 op/s + frag_iter 44,291 op/s + entity_cycle 7,556 op/s + add_remove 21,209 op/s +``` + +**After Phase 1:** +``` +objecs + packed_5 156,720 op/s (+12%) + simple_iter 155,658 op/s (+92%) + frag_iter 49,980 op/s (+13%) + entity_cycle 3,970 op/s (-47%) + add_remove 22,525 op/s (+6%) +``` + +**Comparison with miniplex after Phase 1:** +| Benchmark | objecs | miniplex | Diff | +|-----------|--------|----------|------| +| packed_5 | 156,720 | 183,637 | -15% | +| simple_iter | 155,658 | 161,876 | -4% | +| frag_iter | 49,980 | 51,206 | -2% | +| entity_cycle | 3,970 | 2,247 | +77% ✅ | +| add_remove | 22,525 | 2,092 | +977% ✅ | + +**Key changes:** +- Created `EntityCollection` class with Array + Map internals +- Added `ReadonlyEntityCollection` interface (simpler than `ReadonlySet` to avoid ES2024 Iterator Helpers complexity) +- Implemented swap-and-pop O(1) removal +- Delegated iteration to native array iterator for maximum performance + +**Tradeoff:** entity_cycle regressed 47% from baseline, but still 77% faster than miniplex. The regression is likely due to Map overhead for index tracking. + +### Phase 2 Results +- [ ] Pending + +### Phase 3 Results +- [ ] Pending + +### Final Results +- [ ] Pending From d013eeceaa2095bf948e733b014b59347ec37bf3 Mon Sep 17 00:00:00 2001 From: Jake Klassen Date: Fri, 19 Dec 2025 09:12:39 -0500 Subject: [PATCH 2/9] feat: support adding multiple components correctly --- packages/objecs/src/archetype.ts | 3 +- packages/objecs/src/world.test.ts | 44 ++++++++++++++++++++++++ packages/objecs/src/world.ts | 56 ++++++++++++++++++++++++------- 3 files changed, 89 insertions(+), 14 deletions(-) diff --git a/packages/objecs/src/archetype.ts b/packages/objecs/src/archetype.ts index 451e9c2..58a1324 100644 --- a/packages/objecs/src/archetype.ts +++ b/packages/objecs/src/archetype.ts @@ -72,8 +72,7 @@ export class Archetype< } public addEntity(entity: Entity): this { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - if (this.#entities.has(entity as any)) { + if (this.#entities.has(entity)) { return this; } diff --git a/packages/objecs/src/world.test.ts b/packages/objecs/src/world.test.ts index 59cd81e..eea0805 100644 --- a/packages/objecs/src/world.test.ts +++ b/packages/objecs/src/world.test.ts @@ -157,6 +157,50 @@ describe("World", () => { it.skip("should throw if entity has been mark for deletion"); }); + describe("addEntityComponents() with multiple components", () => { + it("should add multiple components at once", () => { + const world = new World(); + const entity = world.createEntity(); + + world.addEntityComponents(entity, { + color: "red", + rectangle: { width: 10, height: 10 }, + velocity: { x: 5, y: 5 }, + }); + + expect(entity.color).toBe("red"); + expect(entity.rectangle).toEqual({ width: 10, height: 10 }); + expect(entity.velocity).toEqual({ x: 5, y: 5 }); + }); + + it("should update archetype membership correctly", () => { + const world = new World(); + const entity = world.createEntity(); + + const colorArchetype = world.archetype("color"); + const colorRectArchetype = world.archetype("color", "rectangle"); + + expect(colorArchetype.entities.size).toBe(0); + expect(colorRectArchetype.entities.size).toBe(0); + + world.addEntityComponents(entity, { + color: "blue", + rectangle: { width: 20, height: 20 }, + }); + + expect(colorArchetype.entities.size).toBe(1); + expect(colorRectArchetype.entities.size).toBe(1); + }); + + it("should throw if entity does not exist", () => { + const world = new World(); + + expect(() => { + world.addEntityComponents({}, { color: "red" }); + }).toThrow("Entity does not exist"); + }); + }); + describe("archetype()", () => { it("should return the correct archetype", () => { const world = new World(); diff --git a/packages/objecs/src/world.ts b/packages/objecs/src/world.ts index 88042a4..904c30b 100644 --- a/packages/objecs/src/world.ts +++ b/packages/objecs/src/world.ts @@ -12,7 +12,11 @@ export type SafeEntity< */ export interface ReadonlyEntityCollection extends Iterable { readonly size: number; - has(value: T): boolean; + /** + * Check if an entity is in the collection. + * Accepts any object to allow checking membership without type narrowing. + */ + has(value: unknown): boolean; forEach( callbackfn: (value: T, value2: T, set: ReadonlyEntityCollection) => void, thisArg?: unknown, @@ -34,8 +38,8 @@ export class EntityCollection implements ReadonlyEntityCollection { return this.#entities.length; } - has(entity: T): boolean { - return this.#indices.has(entity); + has(entity: unknown): boolean { + return this.#indices.has(entity as T); } /** @@ -215,21 +219,48 @@ export class World { return this.#entities._remove(entity); } + /** + * Add a single component to an entity. + */ public addEntityComponents( entity: T, component: Component, value: NonNullable, - ): T & Record { - const existingEntity = this.#entities.has(entity); - - if (!existingEntity) { + ): T & Record; + /** + * Add multiple components to an entity in a single operation. + * More efficient than multiple single-component calls as it only + * updates archetype membership once. + */ + public addEntityComponents< + T extends Entity, + Components extends { [K in keyof Entity]?: NonNullable }, + >( + entity: T, + components: Components, + ): T & { [K in keyof Components]: NonNullable }; + public addEntityComponents( + entity: T, + componentOrComponents: keyof Entity | Record, + value?: unknown, + ): T { + if (!this.#entities.has(entity)) { throw new Error(`Entity does not exist`); } - // This will update the key and value in the map - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - entity[component] = value; + // Single component: addEntityComponents(entity, "key", value) + if (typeof componentOrComponents === "string" && value !== undefined) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + entity[componentOrComponents] = value; + } else { + // Multiple components: addEntityComponents(entity, { key: value, ... }) + const components = componentOrComponents as Record; + for (const key in components) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (entity as any)[key] = components[key]; + } + } for (const archetype of this.#archetypes) { if (archetype.matches(entity)) { @@ -239,7 +270,7 @@ export class World { } } - return entity as T & Record; + return entity; } public removeEntityComponents( @@ -261,4 +292,5 @@ export class World { } } } + } From 9e15a27fa32ee43e0822286653eb30a70539b76f Mon Sep 17 00:00:00 2001 From: Jake Klassen Date: Fri, 19 Dec 2025 09:13:00 -0500 Subject: [PATCH 3/9] chore: some jsdoc updates --- packages/ecs-benchmark/src/cases/miniplex/add_remove.js | 3 +++ packages/ecs-benchmark/src/cases/miniplex/entity_cycle.js | 3 +++ packages/ecs-benchmark/src/cases/miniplex/frag_iter.js | 4 +++- packages/ecs-benchmark/src/cases/miniplex/packed_5.js | 3 +++ packages/ecs-benchmark/src/cases/miniplex/simple_iter.js | 3 +++ 5 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/ecs-benchmark/src/cases/miniplex/add_remove.js b/packages/ecs-benchmark/src/cases/miniplex/add_remove.js index b3907b8..c07315f 100644 --- a/packages/ecs-benchmark/src/cases/miniplex/add_remove.js +++ b/packages/ecs-benchmark/src/cases/miniplex/add_remove.js @@ -1,6 +1,9 @@ // @ts-check import { World } from "miniplex"; +/** + * @param {number} count + */ export default async (count) => { const ecs = new World(); diff --git a/packages/ecs-benchmark/src/cases/miniplex/entity_cycle.js b/packages/ecs-benchmark/src/cases/miniplex/entity_cycle.js index b2a1d0e..67016a2 100644 --- a/packages/ecs-benchmark/src/cases/miniplex/entity_cycle.js +++ b/packages/ecs-benchmark/src/cases/miniplex/entity_cycle.js @@ -1,6 +1,9 @@ // @ts-check import { World } from "miniplex"; +/** + * @param {number} count + */ export default (count) => { const ecs = new World(); diff --git a/packages/ecs-benchmark/src/cases/miniplex/frag_iter.js b/packages/ecs-benchmark/src/cases/miniplex/frag_iter.js index cc6ef03..f7852be 100644 --- a/packages/ecs-benchmark/src/cases/miniplex/frag_iter.js +++ b/packages/ecs-benchmark/src/cases/miniplex/frag_iter.js @@ -1,7 +1,9 @@ // @ts-check import { World } from "miniplex"; -export default async (count) => { +/** + * @param {number} count + */ export default async (count) => { const ecs = new World(); Array.from("ABCDEFGHIJKLMNOPQRSTUVWXYZ").forEach((component) => { diff --git a/packages/ecs-benchmark/src/cases/miniplex/packed_5.js b/packages/ecs-benchmark/src/cases/miniplex/packed_5.js index 078707d..e328462 100644 --- a/packages/ecs-benchmark/src/cases/miniplex/packed_5.js +++ b/packages/ecs-benchmark/src/cases/miniplex/packed_5.js @@ -1,6 +1,9 @@ // @ts-check import { World } from "miniplex"; +/** + * @param {number} count + */ export default async (count) => { const ecs = new World(); diff --git a/packages/ecs-benchmark/src/cases/miniplex/simple_iter.js b/packages/ecs-benchmark/src/cases/miniplex/simple_iter.js index 299dc2d..6f50e3e 100644 --- a/packages/ecs-benchmark/src/cases/miniplex/simple_iter.js +++ b/packages/ecs-benchmark/src/cases/miniplex/simple_iter.js @@ -1,6 +1,9 @@ // @ts-check import { World } from "miniplex"; +/** + * @param {number} count + */ export default async (count) => { const ecs = new World(); From 004117053888b9410528aa56a8631ebd856f6cea Mon Sep 17 00:00:00 2001 From: Jake Klassen Date: Fri, 19 Dec 2025 09:20:35 -0500 Subject: [PATCH 4/9] chore: update release process --- .github/workflows/release.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0acc98..9a6a1be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,10 @@ on: release: types: [created] +permissions: + id-token: write # Required for OIDC + contents: read + jobs: release: runs-on: ubuntu-24.04 @@ -25,5 +29,3 @@ jobs: - run: pnpm i - run: pnpm publish --no-git-checks --access public --filter objecs - env: - NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} From 86cbb5e33c406824f8f2032791289ca0159cd3e8 Mon Sep 17 00:00:00 2001 From: Jake Klassen Date: Fri, 19 Dec 2025 09:21:18 -0500 Subject: [PATCH 5/9] chore: release v0.0.29 --- packages/objecs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/objecs/package.json b/packages/objecs/package.json index e832fec..ca4acdf 100644 --- a/packages/objecs/package.json +++ b/packages/objecs/package.json @@ -1,6 +1,6 @@ { "name": "objecs", - "version": "0.0.28", + "version": "0.0.29", "description": "ECS", "type": "module", "main": "./dist/index.cjs", From 97ba8a6e9b14278b112e7a80c1264f4075a9c470 Mon Sep 17 00:00:00 2001 From: Jake Klassen Date: Fri, 19 Dec 2025 09:27:22 -0500 Subject: [PATCH 6/9] restore version --- packages/objecs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/objecs/package.json b/packages/objecs/package.json index ca4acdf..e832fec 100644 --- a/packages/objecs/package.json +++ b/packages/objecs/package.json @@ -1,6 +1,6 @@ { "name": "objecs", - "version": "0.0.29", + "version": "0.0.28", "description": "ECS", "type": "module", "main": "./dist/index.cjs", From 36f86c941c664c5eedaaffcfecbb04eb50e9f15b Mon Sep 17 00:00:00 2001 From: Jake Klassen Date: Fri, 19 Dec 2025 09:40:53 -0500 Subject: [PATCH 7/9] document internal api methods and fix naming --- packages/objecs/src/archetype.ts | 8 ++++---- packages/objecs/src/world.ts | 20 ++++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/objecs/src/archetype.ts b/packages/objecs/src/archetype.ts index 58a1324..53b7ea6 100644 --- a/packages/objecs/src/archetype.ts +++ b/packages/objecs/src/archetype.ts @@ -78,7 +78,7 @@ export class Archetype< if (this.matches(entity)) { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - this.#entities._add(entity as any); + this.#entities.add(entity as any); } return this; @@ -86,13 +86,13 @@ export class Archetype< public removeEntity(entity: Entity): this { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - this.#entities._remove(entity as any); + this.#entities.remove(entity as any); return this; } clearEntities() { - this.#entities._clear(); + this.#entities.clear(); } /** @@ -126,7 +126,7 @@ export class Archetype< continue; } - entities._add(entity); + entities.add(entity); } const archetype = new Archetype< diff --git a/packages/objecs/src/world.ts b/packages/objecs/src/world.ts index 904c30b..1ad92fa 100644 --- a/packages/objecs/src/world.ts +++ b/packages/objecs/src/world.ts @@ -44,9 +44,9 @@ export class EntityCollection implements ReadonlyEntityCollection { /** * Add an entity. Returns true if added, false if already present. - * @internal + * @remarks Used internally by World and Archetype. */ - _add(entity: T): boolean { + add(entity: T): boolean { if (this.#indices.has(entity)) { return false; } @@ -58,9 +58,9 @@ export class EntityCollection implements ReadonlyEntityCollection { /** * Remove an entity using swap-and-pop for O(1) removal. * Returns true if removed, false if not present. - * @internal + * @remarks Used internally by World and Archetype. */ - _remove(entity: T): boolean { + remove(entity: T): boolean { const index = this.#indices.get(entity); if (index === undefined) { return false; @@ -82,9 +82,9 @@ export class EntityCollection implements ReadonlyEntityCollection { /** * Clear all entities. - * @internal + * @remarks Used internally by World and Archetype. */ - _clear(): void { + clear(): void { this.#entities.length = 0; this.#indices.clear(); } @@ -151,7 +151,7 @@ export class World { } public clearEntities() { - this.#entities._clear(); + this.#entities.clear(); for (const archetype of this.#archetypes) { archetype.clearEntities(); @@ -172,7 +172,7 @@ export class World { }); if (matchesArchetype) { - entities._add(entity); + entities.add(entity); } } @@ -202,7 +202,7 @@ export class World { public createEntity(entity?: T) { const _entity = entity ?? ({} as T); - this.#entities._add(_entity); + this.#entities.add(_entity); for (const archetype of this.#archetypes) { archetype.addEntity(_entity); @@ -216,7 +216,7 @@ export class World { archetype.removeEntity(entity); } - return this.#entities._remove(entity); + return this.#entities.remove(entity); } /** From 12d81697a76e313bddd14b5b109299ceba1f78cb Mon Sep 17 00:00:00 2001 From: Jake Klassen Date: Fri, 19 Dec 2025 09:52:51 -0500 Subject: [PATCH 8/9] chore: release v0.0.29 --- packages/objecs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/objecs/package.json b/packages/objecs/package.json index e832fec..ca4acdf 100644 --- a/packages/objecs/package.json +++ b/packages/objecs/package.json @@ -1,6 +1,6 @@ { "name": "objecs", - "version": "0.0.28", + "version": "0.0.29", "description": "ECS", "type": "module", "main": "./dist/index.cjs", From 3ea6532cf29ba0669f9953adae6c9fca45aead73 Mon Sep 17 00:00:00 2001 From: Jake Klassen Date: Fri, 19 Dec 2025 09:53:38 -0500 Subject: [PATCH 9/9] restore version --- packages/objecs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/objecs/package.json b/packages/objecs/package.json index ca4acdf..e832fec 100644 --- a/packages/objecs/package.json +++ b/packages/objecs/package.json @@ -1,6 +1,6 @@ { "name": "objecs", - "version": "0.0.29", + "version": "0.0.28", "description": "ECS", "type": "module", "main": "./dist/index.cjs",