import { EventBus } from "@suaveplan/events"; import { Archetype } from "../archetype/archetype.js"; import { BitSet } from "../bitset/bitset.js"; import { ComponentRegistry } from "../component-registry/component-registry.js"; import { EntityPool } from "../entity-pool/entity-pool.js"; import { ECS_CODES, EcsError } from "../errors/ecs-error.js"; import { recordExecuteDuration, recordLifecycle, } from "../observability/otel.js"; import { Query } from "../query/query.js"; import { WorldSnapshotSchema } from "../schemas/world.schemas.js"; import { SystemScheduler } from "../system/system.js"; import type { ComponentData, ComponentId, ComponentSchema, Entity, EntitySnapshot, QueryDescriptor, SystemDefinition, WorldApi, WorldEventMap, WorldSnapshot, } from "../types/ecs.types.js"; /** Configuration accepted by {@link createWorld} / {@link World}. */ export interface WorldConfig { /** Hard cap on the number of distinct component types. Default 256. */ readonly maxComponents?: number; } /** * The World is the central container for an ECS instance. It owns the * component registry, entity pool, archetype graph, query cache, and system * scheduler, and emits typed lifecycle events through `world.events`. * * Public mutators ({@link spawn}, {@link despawn}, {@link addComponent}, * {@link removeComponent}, {@link execute}, {@link snapshot}, * {@link restore}) are instrumented with OpenTelemetry counters / histograms. */ export class World implements WorldApi { public readonly events = new EventBus(); public readonly registry: ComponentRegistry; private readonly pool: EntityPool; private readonly scheduler: SystemScheduler; private readonly archetypes: Archetype[] = []; private readonly archetypeMap = new Map(); private readonly entityArchetype = new Map(); public constructor(config?: WorldConfig) { this.registry = new ComponentRegistry(config?.maxComponents); this.pool = new EntityPool(); this.scheduler = new SystemScheduler(); } /** Register a component schema. Idempotent by schema identity. */ public registerComponent( schema: ComponentSchema ): void { this.registry.register(schema); } /** Spawn a new entity in the empty archetype and emit `entitySpawned`. */ public spawn(): Entity { const entity = this.pool.allocate(); const emptyArch = this.getOrCreateArchetype( new BitSet(this.registry.maxComponents), [] ); emptyArch.addEntity(entity, new Map()); this.entityArchetype.set(entity, emptyArch); recordLifecycle("spawn"); this.events.emit("entitySpawned", { entity }); return entity; } /** Despawn `entity`, freeing its ID and emitting `entityDespawned`. */ public despawn(entity: Entity): void { const arch = this.entityArchetype.get(entity); if (arch === undefined) return; arch.removeEntity(entity); this.entityArchetype.delete(entity); this.pool.free(entity); recordLifecycle("despawn"); this.events.emit("entityDespawned", { entity }); } /** True when `entity` is currently alive in this world. */ public isAlive(entity: Entity): boolean { return this.pool.isAlive(entity); } /** * Add a component to `entity`, moving it to a new archetype as needed. * Throws `ECS.ENTITY_NOT_ALIVE` if the entity has been despawned. * No-op when the entity already has the component. */ public addComponent( entity: Entity, schema: ComponentSchema, data?: Partial ): void { if (!this.registry.has(schema)) this.registry.register(schema); const componentId = this.registry.getId(schema); const oldArch = this.entityArchetype.get(entity); if (oldArch === undefined) { throw new EcsError( ECS_CODES.ENTITY_NOT_ALIVE, "error.ecs.entity_not_alive", { context: { entity } } ); } if (oldArch.hasComponent(componentId)) return; const merged = { ...schema.defaults, ...data } as T; this.validateComponent(schema, merged); const oldData = oldArch.removeEntity(entity) as Map< ComponentId, ComponentData >; oldData.set(componentId, merged); const newMask = oldArch.mask.clone(); newMask.set(componentId); const newIds = [...oldArch.getComponentIds(), componentId].sort( (a, b) => a - b ); const newArch = this.getOrCreateArchetype(newMask, newIds); newArch.addEntity(entity, oldData); this.entityArchetype.set(entity, newArch); this.events.emit("componentAdded", { entity, component: schema.name, }); } /** Remove a component from `entity`, moving it to a new archetype. */ public removeComponent(entity: Entity, schema: ComponentSchema): void { const componentId = this.registry.getId(schema); const oldArch = this.entityArchetype.get(entity); if (oldArch === undefined) { throw new EcsError( ECS_CODES.ENTITY_NOT_ALIVE, "error.ecs.entity_not_alive", { context: { entity } } ); } if (!oldArch.hasComponent(componentId)) return; const oldData = oldArch.removeEntity(entity) as Map< ComponentId, ComponentData >; oldData.delete(componentId); const newMask = oldArch.mask.clone(); newMask.unset(componentId); const newIds = oldArch .getComponentIds() .filter((id) => id !== componentId); const newArch = this.getOrCreateArchetype(newMask, newIds); newArch.addEntity(entity, oldData); this.entityArchetype.set(entity, newArch); this.events.emit("componentRemoved", { entity, component: schema.name, }); } /** Read a component value, or `undefined` if absent. */ public getComponent( entity: Entity, schema: ComponentSchema ): T | undefined { const componentId = this.registry.getId(schema); const arch = this.entityArchetype.get(entity); return arch?.getComponent(entity, componentId) as T | undefined; } /** * Partially merge new data into an existing component value. * Throws `ECS.ENTITY_NOT_ALIVE` if the entity is absent and * `ECS.COMPONENT_VALIDATION_FAILED` when the descriptor's Zod schema rejects * the merged result. */ public setComponent( entity: Entity, schema: ComponentSchema, data: Partial ): void { if (!this.registry.has(schema)) this.registry.register(schema); const componentId = this.registry.getId(schema); const arch = this.entityArchetype.get(entity); if (arch === undefined) { throw new EcsError( ECS_CODES.ENTITY_NOT_ALIVE, "error.ecs.entity_not_alive", { context: { entity } } ); } const current = arch.getComponent(entity, componentId) as T | undefined; if (current === undefined) { this.addComponent(entity, schema, data); return; } const merged = { ...current, ...data } as T; this.validateComponent(schema, merged); arch.setComponent(entity, componentId, merged); } /** True when `entity` has the component. */ public hasComponent(entity: Entity, schema: ComponentSchema): boolean { const componentId = this.registry.getId(schema); const arch = this.entityArchetype.get(entity); return arch?.hasComponent(componentId) ?? false; } /** Evaluate a one-off query — for repeated queries register a system. */ public query(descriptor: QueryDescriptor): ReadonlyArray { const q = new Query(descriptor, this.registry); return q.evaluate(this.archetypes); } /** * Register a system. Throws `ECS.SYSTEM_NAME_CONFLICT` if another system * shares the same name. */ public addSystem(system: SystemDefinition): void { if (this.scheduler.has(system.name)) { throw new EcsError( ECS_CODES.SYSTEM_NAME_CONFLICT, "error.ecs.system_name_conflict", { context: { name: system.name } } ); } const schemas: ComponentSchema[] = [ ...system.query.include, ...(system.query.exclude ?? []), ]; for (const schema of schemas) { if (!this.registry.has(schema)) this.registry.register(schema); } this.scheduler.add(system); } /** Remove a system by name. Returns `false` if no match. */ public removeSystem(name: string): boolean { return this.scheduler.remove(name); } /** * Run every enabled system in scheduler order. Records the wall-clock * duration in the `ecs.world.execute_duration_ms` histogram. */ public execute(dt: number): void { const start = performance.now(); const ordered = this.scheduler.getOrdered(); for (const system of ordered) { if (!system.enabled) continue; const q = new Query(system.query, this.registry); const entities = q.evaluate(this.archetypes); system.execute({ entities, world: this, dt }); } recordExecuteDuration(performance.now() - start); } /** Capture a structured snapshot of every alive entity. */ public snapshot(): WorldSnapshot { const entities: EntitySnapshot[] = []; for (const [entity, arch] of this.entityArchetype) { const components: { readonly name: string; readonly data: ComponentData; }[] = []; for (const cid of arch.getComponentIds()) { const schema = this.registry.getSchema(cid) as ComponentSchema; const data = arch.getComponent(entity, cid) as ComponentData; components.push({ name: schema.name, data: structuredClone(data), }); } entities.push({ id: entity, components }); } return { entities, nextEntityId: this.pool.peekNextId }; } /** * Restore world state from a snapshot. The snapshot is Zod-validated; * `ECS.SNAPSHOT_INVALID` is thrown on failure. Component schemas * referenced by name must already be registered — unknown names are * silently skipped. */ public restore(snap: WorldSnapshot): void { const parsed = WorldSnapshotSchema.safeParse(snap); if (!parsed.success) { throw new EcsError( ECS_CODES.SNAPSHOT_INVALID, "error.ecs.snapshot_invalid", { context: { issues: parsed.error.issues } } ); } this.clear(); this.pool.setNextId(parsed.data.nextEntityId); for (const es of parsed.data.entities) { const entity = this.pool.allocate(); const emptyArch = this.getOrCreateArchetype( new BitSet(this.registry.maxComponents), [] ); emptyArch.addEntity(entity, new Map()); this.entityArchetype.set(entity, emptyArch); for (const comp of es.components) { const schema = this.registry.getSchemaByName(comp.name); if (schema !== undefined) { this.addComponent(entity, schema, comp.data); } } } } /** Remove every entity and archetype. Systems and registrations are kept. */ public clear(): void { this.entityArchetype.clear(); this.archetypes.length = 0; this.archetypeMap.clear(); this.pool.clear(); } /** Number of alive entities. */ public get entityCount(): number { return this.pool.aliveCount; } /** Number of distinct archetypes currently held. */ public get archetypeCount(): number { return this.archetypes.length; } /** Read-only snapshot of every registered system. */ public getSystems(): ReadonlyArray { return this.scheduler.getAll(); } private getOrCreateArchetype( mask: BitSet, componentIds: ReadonlyArray ): Archetype { const key = mask.toKey(); let arch = this.archetypeMap.get(key); if (arch === undefined) { arch = new Archetype(mask, componentIds); this.archetypes.push(arch); this.archetypeMap.set(key, arch); } return arch; } private validateComponent( schema: ComponentSchema, value: T ): void { if (schema.schema === undefined) return; const result = schema.schema.safeParse(value); if (!result.success) { throw new EcsError( ECS_CODES.COMPONENT_VALIDATION_FAILED, "error.ecs.component_validation_failed", { context: { name: schema.name, issues: result.error.issues, }, } ); } } } /** Factory wrapper around `new World(config)`. */ export function createWorld(config?: WorldConfig): World { return new World(config); }