Skip to content

Commit 1c1a6e6

Browse files
jonit-devclaude
andcommitted
refactor: Implement isolated engine instances for ECS system
Major architectural improvement to support multiple isolated ECS instances running in parallel, enabling multi-world scenarios and better testing. Key changes: - ComponentRegistry now supports instance mode with injected world - EntityManager accepts world/registry injection for isolation - EntityQueries refactored to work with instance-specific managers - IndexEventAdapter takes manager dependencies instead of using singletons - createEngineInstance now creates fully isolated ECS stacks - Core components registration accepts optional registry parameter - Removed verbose logging from component registration - Fixed test order in persistent-id-management to reset before refresh - Fixed linting errors (unused imports, require() anti-patterns) Benefits: - Multiple game worlds can run independently - Better test isolation without singleton pollution - Easier to reason about dependencies - Cleaner separation of concerns 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent c7aabbb commit 1c1a6e6

21 files changed

Lines changed: 582 additions & 233 deletions

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"@typescript-eslint/eslint-plugin": "^8.31.0",
7373
"@typescript-eslint/parser": "^8.31.0",
7474
"@vitejs/plugin-react": "^4.4.1",
75+
"@vitest/coverage-v8": "3.2.3",
7576
"@vitest/ui": "^3.2.3",
7677
"autoprefixer": "^10.4.21",
7778
"eslint": "^9.25.1",

src/core/lib/ecs/ComponentRegistry.ts

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,13 @@
44
*/
55
/* eslint-disable @typescript-eslint/no-explicit-any */
66

7-
import { addComponent, defineComponent, hasComponent, removeComponent, Component } from 'bitecs';
7+
import { addComponent, Component, defineComponent, hasComponent, removeComponent } from 'bitecs';
88
import { z } from 'zod';
99

1010
import { emit } from '../events';
1111
import { Logger } from '../logger';
1212
import { ECSWorld } from './World';
1313
import { EntityId } from './types';
14-
import { EntityQueries } from './queries/entityQueries';
1514

1615
// Base component descriptor interface
1716
export interface IComponentDescriptor<TData = unknown> {
@@ -146,16 +145,23 @@ export class ComponentRegistry {
146145
// Cache for entity queries to avoid repeated scans
147146
private entityQueryCache = new Map<string, { entities: EntityId[]; timestamp: number }>();
148147
private readonly CACHE_TTL = 100; // Cache for 100ms
148+
private _world: any; // BitECS world instance
149149

150150
private get world() {
151-
return ECSWorld.getInstance().getWorld();
151+
return this._world || ECSWorld.getInstance().getWorld();
152152
}
153153

154154
public getWorld() {
155155
return this.world;
156156
}
157157

158-
private constructor() {}
158+
/**
159+
* Constructor can take an optional world instance for isolated instances
160+
* If no world provided, falls back to singleton ECSWorld
161+
*/
162+
constructor(worldInstance?: ECSWorld) {
163+
this._world = worldInstance?.getWorld();
164+
}
159165

160166
static getInstance(): ComponentRegistry {
161167
if (!ComponentRegistry.instance) {
@@ -167,6 +173,7 @@ export class ComponentRegistry {
167173
reset(): void {
168174
this.components.clear();
169175
this.bitECSComponents.clear();
176+
this.entityQueryCache.clear();
170177
}
171178

172179
/**
@@ -187,8 +194,6 @@ export class ComponentRegistry {
187194
if (bitECSComponent) {
188195
this.bitECSComponents.set(descriptor.id, bitECSComponent);
189196
}
190-
191-
this.logger.info(`Registered component: ${descriptor.name} (${descriptor.id})`);
192197
}
193198

194199
/**
@@ -296,6 +301,13 @@ export class ComponentRegistry {
296301
}
297302
}
298303

304+
/**
305+
* Add component for adapter interface (void return)
306+
*/
307+
addComponentForAdapter(entityId: number, componentType: string, data: unknown): void {
308+
this.addComponent(entityId, componentType, data);
309+
}
310+
299311
/**
300312
* Remove component from entity
301313
*/
@@ -522,27 +534,23 @@ export class ComponentRegistry {
522534
return cached.entities;
523535
}
524536

525-
// Try to use efficient EntityQueries system first
526-
const queries = EntityQueries.getInstance();
527-
let result = queries.listEntitiesWithComponent(componentId);
528-
529-
// If EntityQueries returns empty, fall back to manual scan
530-
// This handles cases where indices aren't built yet
531-
if (result.length === 0) {
532-
const bitECSComponent = this.bitECSComponents.get(componentId);
533-
if (bitECSComponent) {
534-
const entitySet = new Set<EntityId>();
535-
536-
// Use a smaller upper bound for better performance
537-
// Most scenes won't have more than 1000 entities
538-
for (let eid = 0; eid < 1000; eid++) {
539-
if (hasComponent(this.world, bitECSComponent, eid)) {
540-
entitySet.add(eid);
541-
}
542-
}
537+
// Scan for entities with this component
538+
// Note: We used to use EntityQueries here, but that caused issues with instance isolation
539+
// The manual scan is fast enough for most use cases (typically <1000 entities)
540+
let result: EntityId[] = [];
541+
const bitECSComponent = this.bitECSComponents.get(componentId);
542+
if (bitECSComponent) {
543+
const entitySet = new Set<EntityId>();
543544

544-
result = Array.from(entitySet).sort((a, b) => a - b);
545+
// Use a smaller upper bound for better performance
546+
// Most scenes won't have more than 1000 entities
547+
for (let eid = 0; eid < 1000; eid++) {
548+
if (hasComponent(this.world, bitECSComponent, eid)) {
549+
entitySet.add(eid);
550+
}
545551
}
552+
553+
result = Array.from(entitySet).sort((a, b) => a - b);
546554
}
547555

548556
// Cache the result
@@ -551,6 +559,17 @@ export class ComponentRegistry {
551559
return result;
552560
}
553561

562+
/**
563+
* Get components for entity in adapter format (for SceneDeserializer)
564+
*/
565+
getComponentsForEntityForAdapter(entityId: number): Array<{ type: string; data: unknown }> {
566+
const componentIds = this.getEntityComponents(entityId);
567+
return componentIds.map((componentId) => ({
568+
type: componentId,
569+
data: this.getComponentData(entityId, componentId),
570+
}));
571+
}
572+
554573
/**
555574
* Get components for entity in old format (legacy compatibility)
556575
*/

src/core/lib/ecs/EntityManager.ts

Lines changed: 94 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import { Logger } from '../logger';
55
import { EntityMeta } from './BitECSComponents';
66
import { componentRegistry, ComponentRegistry } from './ComponentRegistry';
77
import {
8+
clearPersistentIdMaps,
89
generatePersistentId,
910
PersistentIdSchema,
10-
clearPersistentIdMaps,
1111
} from './components/definitions/PersistentIdComponent';
1212
import { getEntityName, getEntityParent, setEntityMeta } from './DataConversion';
1313
import { IEntity } from './IEntity';
@@ -28,62 +28,79 @@ export class EntityManager {
2828
private eventListeners: EntityEventListener[] = [];
2929
private entityCache: Map<EntityId, IEntity> = new Map();
3030
private existingPersistentIds: Set<string> = new Set();
31-
private queries: EntityQueries;
31+
private queries: EntityQueries | null = null;
3232
private world: any; // BitECS world - using any for compatibility with bitecs
3333
private componentRegistry: ComponentRegistry;
3434
private logger = Logger.create('EntityManager');
35+
private isInstanceMode = false;
3536

3637
constructor(world?: any, componentManager?: ComponentRegistry) {
3738
if (world) {
3839
// Instance mode with injected world and optional component manager
40+
this.isInstanceMode = true;
3941
this.world = world;
40-
this.queries = new EntityQueries(world);
42+
// Note: EntityQueries will be set later via setEntityQueries to avoid circular dependency
4143
this.componentRegistry = componentManager || componentRegistry;
4244
} else {
4345
// Singleton mode (backward compatibility)
46+
this.isInstanceMode = false;
4447
this.world = ECSWorld.getInstance().getWorld();
4548
this.queries = EntityQueries.getInstance();
4649
this.componentRegistry = componentRegistry;
4750
}
4851
}
4952

53+
/**
54+
* Set the EntityQueries instance for this manager (used in instance mode to avoid circular dependency)
55+
*/
56+
public setEntityQueries(queries: EntityQueries): void {
57+
this.queries = queries;
58+
}
59+
5060
public static getInstance(): EntityManager {
5161
if (!EntityManager.instance) {
5262
EntityManager.instance = new EntityManager();
5363
}
5464
return EntityManager.instance;
5565
}
5666

57-
public reset(): void {
67+
public reset(newWorld?: any): void {
5868
this.entityCache.clear();
5969
this.existingPersistentIds.clear();
60-
this.eventListeners = [];
70+
// Note: We do NOT clear eventListeners here because external components
71+
// (like EntityQueries/IndexEventAdapter) need to stay attached to receive
72+
// events about new entities created after the reset.
73+
// this.eventListeners = [];
6174

6275
// Clear persistent ID mappings
6376
clearPersistentIdMaps();
6477

65-
// Refresh world reference in case ECSWorld singleton was reset
66-
this.refreshWorld();
78+
if (this.isInstanceMode) {
79+
// For instance mode, optionally update world and rebuild indices
80+
if (newWorld) {
81+
this.world = newWorld;
82+
}
83+
this.queries?.reset();
84+
return;
85+
}
6786

68-
// Reset EntityQueries to rebuild indices with new world state
69-
this.queries.reset();
87+
// Singleton mode: refresh world reference and reset indices
88+
this.refreshWorld();
89+
this.queries?.reset();
7090
}
7191

7292
/**
7393
* Refresh world reference from singleton (used after ECSWorld reset)
7494
*/
7595
public refreshWorld(): void {
76-
// Always refresh for singleton instances (instance was created without injected world)
77-
// We can't reliably check if this.world === ECSWorld.getInstance().getWorld()
78-
// because the world may have been reset, so we check if we're a singleton instance
79-
const currentSingletonWorld = ECSWorld.getInstance().getWorld();
96+
if (this.isInstanceMode) {
97+
// In instance mode, world is managed externally by the factory; no-op here
98+
return;
99+
}
80100

81-
// Force refresh the world reference to current singleton world
101+
const currentSingletonWorld = ECSWorld.getInstance().getWorld();
82102
this.world = currentSingletonWorld;
83-
// Also update queries with new world
84103
this.queries = EntityQueries.getInstance();
85-
86-
// Clear cache and rebuild persistent ID tracking
87104
this.entityCache.clear();
88105
this.rebuildPersistentIdCache();
89106
}
@@ -202,7 +219,7 @@ export class EntityManager {
202219

203220
const entities = this.getAllEntities();
204221
entities.forEach((entity) => {
205-
const persistentIdData = componentRegistry.getComponentData<{ id: string }>(
222+
const persistentIdData = this.componentRegistry.getComponentData<{ id: string }>(
206223
entity.id,
207224
'PersistentId',
208225
);
@@ -253,6 +270,18 @@ export class EntityManager {
253270
return entity;
254271
}
255272

273+
/**
274+
* Create entity for adapter interface (returns { id: number })
275+
*/
276+
createEntityForAdapter(
277+
name: string,
278+
parentId?: number | null,
279+
persistentId?: string,
280+
): { id: number } {
281+
const entity = this.createEntity(name, parentId, persistentId);
282+
return { id: entity.id };
283+
}
284+
256285
getEntity(id: EntityId): IEntity | undefined {
257286
if (this.entityCache.has(id)) {
258287
return this.entityCache.get(id);
@@ -269,10 +298,34 @@ export class EntityManager {
269298
// Rebuild cache from BitECS world using efficient indexed lookup
270299
this.entityCache.clear();
271300

301+
// Return entities for adapter interface
302+
return this.getAllEntitiesForAdapter();
303+
}
304+
305+
/**
306+
* Get all entities in adapter format (for SceneDeserializer compatibility)
307+
*/
308+
private getAllEntitiesForAdapter(): Array<{
309+
id: number;
310+
name: string;
311+
parentId?: number | null;
312+
}> {
313+
const entities = this.getAllEntitiesInternal();
314+
return entities.map((entity) => ({
315+
id: entity.id,
316+
name: entity.name,
317+
parentId: entity.parentId,
318+
}));
319+
}
320+
321+
/**
322+
* Internal method to get all entities
323+
*/
324+
private getAllEntitiesInternal(): IEntity[] {
272325
// Get all entity IDs - use scan as fallback if queries not ready
273326
let entityIds: number[];
274327
try {
275-
entityIds = this.queries.listAllEntities();
328+
entityIds = this.queries?.listAllEntities() || [];
276329

277330
// If queries return empty but we know entities exist, fall back to scan
278331
if (entityIds.length === 0) {
@@ -322,7 +375,7 @@ export class EntityManager {
322375
try {
323376
const queriesAvailable = this.queries && this.queries.listAllEntities().length > 0;
324377
entities.forEach((entity) => {
325-
if (queriesAvailable) {
378+
if (queriesAvailable && this.queries) {
326379
entity.children = this.queries.getChildren(entity.id);
327380
} else {
328381
// Fall back to filtering
@@ -352,7 +405,10 @@ export class EntityManager {
352405
if (!entity) return false;
353406

354407
// Remove persistent ID from tracking
355-
const persistentIdData = componentRegistry.getComponentData<{ id: string }>(id, 'PersistentId');
408+
const persistentIdData = this.componentRegistry.getComponentData<{ id: string }>(
409+
id,
410+
'PersistentId',
411+
);
356412
if (persistentIdData && persistentIdData.id) {
357413
this.existingPersistentIds.delete(persistentIdData.id);
358414
}
@@ -426,7 +482,7 @@ export class EntityManager {
426482
getRootEntities(): IEntity[] {
427483
// Try to use efficient indexed query, fall back if queries not ready
428484
try {
429-
const rootEntityIds = this.queries.getRootEntities();
485+
const rootEntityIds = this.queries?.getRootEntities() || [];
430486

431487
// If result is empty, verify with fallback to avoid race conditions
432488
if (rootEntityIds.length === 0) {
@@ -469,6 +525,13 @@ export class EntityManager {
469525
return true;
470526
}
471527

528+
/**
529+
* Set parent for adapter interface (void return)
530+
*/
531+
setParentForAdapter(childId: number, parentId?: number | null): void {
532+
this.setParent(childId, parentId);
533+
}
534+
472535
setParent(entityId: EntityId, newParentId?: EntityId): boolean {
473536
const entity = this.getEntity(entityId);
474537
if (!entity) return false;
@@ -522,6 +585,13 @@ export class EntityManager {
522585
return true;
523586
}
524587

588+
/**
589+
* Clear entities for adapter interface
590+
*/
591+
clearEntitiesForAdapter(): void {
592+
this.clearEntities();
593+
}
594+
525595
private wouldCreateCircularDependency(entityId: EntityId, potentialParentId: EntityId): boolean {
526596
let currentId: EntityId | null = potentialParentId;
527597

@@ -548,7 +618,7 @@ export class EntityManager {
548618
* @returns The persistent ID string, or undefined if not found
549619
*/
550620
getEntityPersistentId(entityId: EntityId): string | undefined {
551-
const persistentIdData = componentRegistry.getComponentData<{ id: string }>(
621+
const persistentIdData = this.componentRegistry.getComponentData<{ id: string }>(
552622
entityId,
553623
'PersistentId',
554624
);
@@ -581,7 +651,7 @@ export class EntityManager {
581651
const entities = this.getAllEntities();
582652

583653
for (const entity of entities) {
584-
const persistentIdData = componentRegistry.getComponentData<{ id: string }>(
654+
const persistentIdData = this.componentRegistry.getComponentData<{ id: string }>(
585655
entity.id,
586656
'PersistentId',
587657
);

0 commit comments

Comments
 (0)