feat: implement core ECS engine with RxJS observability

Initial implementation of an Entity-Component-System (ECS) featuring:
- Sparse set-based component storage for efficient access.
- Entity lifecycle management with generation-based recycling.
- Reactive query system using RxJS for change tracking.
- Batched event flushing to support frame-based updates.
- Type-safe component definitions via TypeScript inference.
This commit is contained in:
hypercross 2026-05-31 15:45:20 +08:00
commit 4ede2d7f3b
14 changed files with 2427 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo

1533
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "ecs-observable",
"version": "0.1.0",
"description": "Entity-Component-System for games with an Observable-style API",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"prepublishOnly": "npm run build"
},
"peerDependencies": {
"rxjs": "^7.0.0"
},
"devDependencies": {
"rxjs": "^7.8.1",
"tsup": "^8.3.5",
"typescript": "^5.6.0"
},
"keywords": [
"ecs",
"entity-component-system",
"rxjs",
"observable",
"game"
],
"license": "MIT"
}

33
src/component.ts Normal file
View File

@ -0,0 +1,33 @@
// ── Component ─────────────────────────────────────────
/**
* A component definition carries both the type shape (via TypeScript inference)
* and a unique key used for storage lookup.
*
* @example
* ```ts
* const Position = defineComponent({ x: 0, y: 0 });
* type Position = typeof Position.type;
* ```
*/
export interface ComponentDef<T extends Record<string, any>> {
/** Unique symbol used as the storage key. */
readonly _key: symbol;
/** Default values applied when a component is first added. */
readonly defaults: T;
/** Phantom type for inference. */
readonly type: T;
}
/**
* Define a component type. The argument provides both default values and the
* TypeScript shape.
*/
export function defineComponent<T extends Record<string, any>>(
defaults: T
): ComponentDef<T> {
return {
_key: Symbol(),
defaults: { ...defaults },
type: undefined as unknown as T, // phantom; never read at runtime
};
}

18
src/entity.ts Normal file
View File

@ -0,0 +1,18 @@
// ── Entity ─────────────────────────────────────────────
/** Opaque entity identifier. */
export type Entity = number & { readonly __entity: unique symbol };
/** Creates a new typed Entity from a raw number. */
export function makeEntity(index: number, generation: number): Entity {
return ((generation << 20) | index) as Entity;
}
/** Extract the index from an Entity. */
export function entityIndex(entity: Entity): number {
return entity & 0xfffff;
}
/** Extract the generation from an Entity. */
export function entityGeneration(entity: Entity): number {
return entity >>> 20;
}

10
src/index.ts Normal file
View File

@ -0,0 +1,10 @@
// ── Public API ─────────────────────────────────────────
export { World } from './world';
export { defineComponent } from './component';
export type { ComponentDef } from './component';
export { query } from './query';
export { Query } from './query';
export type { Entity } from './entity';
export { makeEntity, entityIndex, entityGeneration } from './entity';
export { SparseSet } from './storage/sparse-set';
export type { WorldEvent, QueryUpdate } from './observable/events';

55
src/observable/events.ts Normal file
View File

@ -0,0 +1,55 @@
import type { ComponentDef } from '../component';
import type { Entity } from '../entity';
// ── World Events ──────────────────────────────────────
/**
* Discriminated union of all world-level events.
* Emitted via `world.events$`.
*/
export type WorldEvent =
| SpawnedEvent
| DestroyedEvent
| ComponentAddedEvent
| ComponentRemovedEvent
| ComponentChangedEvent;
export interface SpawnedEvent {
type: 'spawned';
entity: Entity;
}
export interface DestroyedEvent {
type: 'destroyed';
entity: Entity;
}
export interface ComponentAddedEvent {
type: 'componentAdded';
entity: Entity;
component: ComponentDef<any>;
}
export interface ComponentRemovedEvent {
type: 'componentRemoved';
entity: Entity;
component: ComponentDef<any>;
}
export interface ComponentChangedEvent {
type: 'componentChanged';
entity: Entity;
component: ComponentDef<any>;
}
// ── Query Observables ────────────────────────────────
/**
* Emitted by `world.observe(query)` when the result set changes.
*/
export interface QueryUpdate {
/** Entities that newly match the query this frame. */
added: Entity[];
/** Entities that no longer match the query this frame. */
removed: Entity[];
/** Entities still matching that had a `markDirty` this frame. */
changed: Entity[];
}

149
src/observable/observe.ts Normal file
View File

@ -0,0 +1,149 @@
import { Subject } from "rxjs";
import type { Query } from "../query";
import type { Entity } from "../entity";
import type { WorldEvent, QueryUpdate } from "./events";
// ── Internal observer state per query ────────────────
interface QueryObserverState {
query: Query;
/** Cached set of entities currently matching the query. */
matched: Set<Entity>;
subject: Subject<QueryUpdate>;
}
// ── Observable layer ─────────────────────────────────
/**
* Manages observable subscriptions for a World.
* Kept separate from the World class for clarity.
*/
export class ObservableLayer {
/** Raw event stream. */
readonly events$ = new Subject<WorldEvent>();
/** Active query observers. */
private _observers: QueryObserverState[] = [];
/**
* Get or create a Subject for a query.
* If this is the first subscription, seed the matched set using
* the provided queryMatches callback.
*/
observe(query: Query): Subject<QueryUpdate> {
const existing = this._observers.find(
(o) => o.query === query || queriesEqual(o.query, query),
);
if (existing) return existing.subject;
const state: QueryObserverState = {
query,
matched: new Set(),
subject: new Subject<QueryUpdate>(),
};
// Seeding is handled by World (we don't have entity iteration here).
// The World's observe() method seeds via a separate path.
this._observers.push(state);
return state.subject;
}
/**
* Seed the initial matched set for an observer.
* Called once by World.observe() with all currently-matching entities.
*/
seed(query: Query, entities: Entity[]): void {
const obs = this._observers.find(
(o) => o.query === query || queriesEqual(o.query, query),
);
if (!obs) return;
for (const e of entities) {
obs.matched.add(e);
}
}
/**
* Feed an event into the observable system.
* Called by the World after state mutation.
*/
onEvent(
event: WorldEvent,
queryMatches: (query: Query, e: Entity) => boolean,
): void {
// Forward to the global stream
this.events$.next(event);
// Update each observer
for (const observer of this._observers) {
this._updateObserver(observer, event, queryMatches);
}
}
private _updateObserver(
obs: QueryObserverState,
event: WorldEvent,
queryMatches: (query: Query, e: Entity) => boolean,
): void {
const e = event.entity;
const wasMatched = obs.matched.has(e);
const nowMatches = queryMatches(obs.query, e);
switch (event.type) {
case "spawned":
// Entity is bare; won't match unless components added later
break;
case "destroyed":
if (wasMatched) {
obs.matched.delete(e);
obs.subject.next({ added: [], removed: [e], changed: [] });
}
break;
case "componentAdded":
case "componentRemoved": {
if (wasMatched && !nowMatches) {
obs.matched.delete(e);
obs.subject.next({ added: [], removed: [e], changed: [] });
} else if (!wasMatched && nowMatches) {
obs.matched.add(e);
obs.subject.next({ added: [e], removed: [], changed: [] });
}
break;
}
case "componentChanged": {
if (wasMatched && nowMatches) {
obs.subject.next({ added: [], removed: [], changed: [e] });
}
break;
}
}
}
/** Reset all observer state (useful for tests). */
reset(): void {
for (const obs of this._observers) {
obs.subject.complete();
obs.matched.clear();
}
this._observers = [];
}
/** Complete all streams. */
complete(): void {
this.events$.complete();
for (const obs of this._observers) {
obs.subject.complete();
}
this._observers = [];
}
}
function queriesEqual(a: Query, b: Query): boolean {
if (a.with.length !== b.with.length) return false;
if (a.not.length !== b.not.length) return false;
return (
a.with.every((c, i) => c === b.with[i]) &&
a.not.every((c, i) => c === b.not[i])
);
}

40
src/query.ts Normal file
View File

@ -0,0 +1,40 @@
import type { ComponentDef } from "./component";
// ── Query ─────────────────────────────────────────────
/**
* Describes a component filter.
*
* Use `query()` to construct one, optionally chaining `.without()`.
*
* @example
* ```ts
* query(Position, Velocity) // entities with both
* query(Position).without(Dead) // Position but not Dead
* ```
*/
export class Query {
/** Components an entity must have to match. */
readonly with: ComponentDef<any>[];
/** Components an entity must NOT have to match. */
readonly not: ComponentDef<any>[];
constructor(
withComponents: ComponentDef<any>[],
withoutComponents: ComponentDef<any>[] = [],
) {
this.with = withComponents;
this.not = withoutComponents;
}
/** Return a new Query that also excludes these components. */
without(...components: ComponentDef<any>[]): Query {
return new Query(this.with, [...this.not, ...components]);
}
}
/**
* Build a query matching entities that have all the given components.
*/
export function query(...components: ComponentDef<any>[]): Query {
return new Query(components);
}

102
src/storage/sparse-set.ts Normal file
View File

@ -0,0 +1,102 @@
// ── SparseSet ─────────────────────────────────────────
/**
* Type-compatible sparse set. Each entity index maps to a value in the dense
* array. The sparse array stores indices into the dense array (or `-1`).
*
* Inspired by ENTT's storage model.
*/
export class SparseSet<T> {
private sparse: number[] = [];
private dense: number[] = []; // entity indices
private values: T[] = [];
/** Number of elements currently stored. */
get size(): number {
return this.dense.length;
}
/** Returns true if the entity has a value. */
has(entityIndex: number): boolean {
return (
entityIndex < this.sparse.length &&
this.sparse[entityIndex] !== -1
);
}
/** Get the value for an entity. Throws if absent. */
get(entityIndex: number): T {
const idx = this.sparse[entityIndex];
return this.values[idx];
}
/** Get the value for an entity, or undefined. */
tryGet(entityIndex: number): T | undefined {
const idx = this.sparse[entityIndex];
if (idx === undefined || idx === -1) return undefined;
return this.values[idx];
}
/** Insert or replace a value for an entity. */
set(entityIndex: number, value: T): void {
if (this.has(entityIndex)) {
const idx = this.sparse[entityIndex];
this.values[idx] = value;
} else {
this._grow(entityIndex);
this.sparse[entityIndex] = this.dense.length;
this.dense.push(entityIndex);
this.values.push(value);
}
}
/** Remove the value for an entity. Safe to call even if absent. */
remove(entityIndex: number): boolean {
if (!this.has(entityIndex)) return false;
const idx = this.sparse[entityIndex];
const lastIdx = this.dense.length - 1;
if (idx !== lastIdx) {
// swap-remove: move the last element into the removed slot
const lastEntity = this.dense[lastIdx];
this.dense[idx] = lastEntity;
this.values[idx] = this.values[lastIdx];
this.sparse[lastEntity] = idx;
}
this.dense.pop();
this.values.pop();
this.sparse[entityIndex] = -1;
return true;
}
/** Iterate entity indices in dense order. */
entities(): IterableIterator<number> {
return this.dense[Symbol.iterator]();
}
/** Iterate values in dense order. */
rawValues(): readonly T[] {
return this.values;
}
/** Iterate [entityIndex, value] pairs in dense order. */
*entries(): IterableIterator<[number, T]> {
for (let i = 0; i < this.dense.length; i++) {
yield [this.dense[i], this.values[i]];
}
}
/** Clear all entries. */
clear(): void {
this.sparse = [];
this.dense = [];
this.values = [];
}
private _grow(to: number): void {
while (this.sparse.length <= to) {
this.sparse.push(-1);
}
}
}

284
src/world.ts Normal file
View File

@ -0,0 +1,284 @@
import type { ComponentDef } from "./component";
import type { Entity } from "./entity";
import { makeEntity, entityIndex, entityGeneration } from "./entity";
import type { Query } from "./query";
import { SparseSet } from "./storage/sparse-set";
import { ObservableLayer } from "./observable/observe";
import type { QueryUpdate } from "./observable/events";
import { Observable } from "rxjs";
// ── World ─────────────────────────────────────────────
/**
* The central ECS container.
*
* Manages entities, components, queries, and change tracking.
* Call `flush()` once per frame to emit batched observable events.
*/
export class World {
// ── Entity pools ──────────────────────────────────
private _generations: number[] = [];
private _free: number[] = [];
// ── Component storage ─────────────────────────────
private _components = new Map<symbol, SparseSet<any>>();
private _keyToDef = new Map<symbol, ComponentDef<any>>();
// ── Change tracking ───────────────────────────────
private _dirty = new Map<symbol, Set<number>>();
// ── Observable layer ──────────────────────────────
private _observable = new ObservableLayer();
/** Global event stream. */
get events$(): Observable<any> {
return this._observable.events$.asObservable();
}
// ── Entity lifecycle ──────────────────────────────
/** Create a new (bare) entity. */
spawn(): Entity {
if (this._free.length > 0) {
const idx = this._free.pop()!;
const gen = this._generations[idx];
const e = makeEntity(idx, gen);
this._emit({ type: "spawned", entity: e });
return e;
}
const idx = this._generations.length;
this._generations.push(1);
const e = makeEntity(idx, 1);
this._emit({ type: "spawned", entity: e });
return e;
}
/** Destroy an entity, removing all its components. */
destroy(entity: Entity): void {
const idx = entityIndex(entity);
if (!this._isAlive(idx, entity)) return;
for (const [, store] of this._components) {
store.remove(idx);
}
for (const [, dirty] of this._dirty) {
dirty.delete(idx);
}
this._generations[idx]++;
this._free.push(idx);
this._emit({ type: "destroyed", entity });
}
/** Returns true if the entity is still alive. */
isAlive(entity: Entity): boolean {
return this._isAlive(entityIndex(entity), entity);
}
// ── Component operations ──────────────────────────
/** Add a component to an entity. Returns the live value. */
add<T extends Record<string, any>>(
entity: Entity,
def: ComponentDef<T>,
init?: Partial<T>,
): T {
const idx = entityIndex(entity);
this._assertAlive(idx, entity);
const store = this._getOrCreateStore(def);
const value = { ...def.defaults, ...init };
store.set(idx, value);
this._emit({ type: "componentAdded", entity, component: def });
return value;
}
/** Remove a component from an entity. */
remove(entity: Entity, def: ComponentDef<any>): void {
const idx = entityIndex(entity);
this._assertAlive(idx, entity);
const store = this._components.get(def._key);
if (!store) return;
const removed = store.remove(idx);
this._dirty.get(def._key)?.delete(idx);
if (removed) {
this._emit({ type: "componentRemoved", entity, component: def });
}
}
/** Get a mutable reference to a component. Throws if absent. */
get<T extends Record<string, any>>(entity: Entity, def: ComponentDef<T>): T {
const idx = entityIndex(entity);
this._assertAlive(idx, entity);
const store = this._components.get(def._key);
if (!store || !store.has(idx)) {
throw new Error(
`Entity ${entity} does not have the requested component.`,
);
}
return store.get(idx);
}
/** Get a mutable reference, or undefined if absent. */
tryGet<T extends Record<string, any>>(
entity: Entity,
def: ComponentDef<T>,
): T | undefined {
const idx = entityIndex(entity);
if (!this._isAlive(idx, entity)) return undefined;
return this._components.get(def._key)?.tryGet(idx);
}
/** Check if an entity has a component. */
has(entity: Entity, def: ComponentDef<any>): boolean {
const idx = entityIndex(entity);
if (!this._isAlive(idx, entity)) return false;
const store = this._components.get(def._key);
return store?.has(idx) ?? false;
}
/** Replace a component value. Sets the value and marks dirty. */
set<T extends Record<string, any>>(
entity: Entity,
def: ComponentDef<T>,
value: T,
): void {
const idx = entityIndex(entity);
this._assertAlive(idx, entity);
const store = this._components.get(def._key);
if (!store || !store.has(idx)) {
throw new Error(
`Entity ${entity} does not have the requested component. ` +
`Use add() to add it first.`,
);
}
store.set(idx, value);
this.markDirty(entity, def);
}
// ── Change tracking ───────────────────────────────
/** Mark entity's component as dirty. Not emitted until `flush()`. */
markDirty(entity: Entity, def: ComponentDef<any>): void {
const idx = entityIndex(entity);
this._assertAlive(idx, entity);
let dirty = this._dirty.get(def._key);
if (!dirty) {
dirty = new Set();
this._dirty.set(def._key, dirty);
}
dirty.add(idx);
}
/** Emit all pending change events. Call once per frame. */
flush(): void {
for (const [key, dirtySet] of this._dirty) {
if (dirtySet.size === 0) continue;
const def = this._keyToDef.get(key);
if (!def) {
dirtySet.clear();
continue;
}
for (const idx of dirtySet) {
const entity = makeEntity(idx, this._generations[idx]);
this._emit({ type: "componentChanged", entity, component: def });
}
dirtySet.clear();
}
}
// ── Queries ───────────────────────────────────────
/** Iterate all entities matching a query synchronously. */
*query(q: Query): IterableIterator<Entity> {
const withStores = q.with.map((d) => this._components.get(d._key));
const withoutStores = q.not.map((d) => this._components.get(d._key));
if (withStores.some((s) => !s)) return;
const primary = withStores.reduce((a, b) => (a!.size <= b!.size ? a : b))!;
for (const idx of primary.entities()) {
if (!withStores.every((s) => s!.has(idx))) continue;
if (withoutStores.some((s) => s?.has(idx))) continue;
yield makeEntity(idx, this._generations[idx]);
}
}
/** Observe changes to a query's result set. */
observe(q: Query): Observable<QueryUpdate> {
const subject = this._observable.observe(q);
// Seed with currently-matching entities
const existing = [...this.query(q)];
this._observable.seed(q, existing);
return subject.asObservable();
}
/** Total number of *alive* entities. */
get entityCount(): number {
let count = 0;
for (let i = 0; i < this._generations.length; i++) {
if (this._generations[i] !== 0 && !this._free.includes(i)) {
count++;
}
}
return count;
}
// ── Internals ─────────────────────────────────────
private _emit(event: import("./observable/events").WorldEvent): void {
this._observable.onEvent(event, this._queryMatches.bind(this));
}
private _isAlive(idx: number, entity: Entity): boolean {
return (
idx < this._generations.length &&
this._generations[idx] !== 0 &&
entityGeneration(entity) === this._generations[idx]
);
}
private _assertAlive(idx: number, entity: Entity): void {
if (!this._isAlive(idx, entity)) {
throw new Error(`Entity ${entity} is not alive.`);
}
}
private _getOrCreateStore<T extends Record<string, any>>(
def: ComponentDef<T>,
): SparseSet<T> {
let store = this._components.get(def._key);
if (!store) {
store = new SparseSet<T>();
this._components.set(def._key, store);
this._keyToDef.set(def._key, def);
}
return store;
}
private _queryMatches(query: Query, entity: Entity): boolean {
const idx = entityIndex(entity);
if (!this._isAlive(idx, entity)) return false;
return (
query.with.every(
(d) => this._components.get(d._key)?.has(idx) ?? false,
) &&
query.not.every((d) => !(this._components.get(d._key)?.has(idx) ?? false))
);
}
}

132
test/smoke.ts Normal file
View File

@ -0,0 +1,132 @@
import {
World,
defineComponent,
query,
QueryUpdate,
WorldEvent,
} from "../src/index";
// ── Define components ─────────────────────────────────
const Position = defineComponent({ x: 0, y: 0 });
const Velocity = defineComponent({ vx: 0, vy: 0 });
const Health = defineComponent({ current: 100, max: 100 });
const Dead = defineComponent({ timestamp: 0 });
// Type inference check
const _p: { x: number; y: number } = Position.defaults;
// ── World setup ──────────────────────────────────────
const world = new World();
let events: WorldEvent[] = [];
world.events$.subscribe((e) => events.push(e));
// ── Entity lifecycle ─────────────────────────────────
const player = world.spawn();
const enemy = world.spawn();
console.assert(events.length === 2, "spawn events");
console.assert(events[0].type === "spawned" && events[0].entity === player);
console.assert(events[1].type === "spawned" && events[1].entity === enemy);
console.assert(world.isAlive(player), "player alive");
console.assert(world.isAlive(enemy), "enemy alive");
console.assert(world.entityCount === 2, "two entities");
// ── Add components ───────────────────────────────────
const pos = world.add(player, Position, { x: 10, y: 20 });
world.add(player, Velocity, { vx: 1, vy: 0 });
world.add(enemy, Position, { x: 50, y: 0 });
world.add(enemy, Health, { current: 50 });
console.assert(pos.x === 10 && pos.y === 20, "add with init");
console.assert(world.has(player, Position), "has Position");
console.assert(world.has(player, Velocity), "has Velocity");
console.assert(!world.has(player, Health), "no Health");
console.assert(events.length === 6, "component add events");
// ── Sync query ───────────────────────────────────────
const movable = [...world.query(query(Position, Velocity))];
console.assert(movable.length === 1, "player only in movable");
console.assert(movable[0] === player);
const allPos = [...world.query(query(Position))];
console.assert(allPos.length === 2, "both have Position");
// ── Observable query ─────────────────────────────────
const queryLog: QueryUpdate[] = [];
world.observe(query(Position, Velocity)).subscribe((u) => {
if (u.added.length || u.removed.length || u.changed.length) {
queryLog.push(u);
}
});
// ── Mutation + change tracking ───────────────────────
world.get(player, Position).x += 5;
world.markDirty(player, Position);
world.get(player, Velocity).vx *= 2;
world.markDirty(player, Velocity);
// flush should emit componentChanged events and update queries
world.flush();
console.assert(
events.some((e) => e.type === "componentChanged"),
"change events",
);
// The query observer should have received changed: [player]
const lastUpdate = queryLog[queryLog.length - 1];
console.assert(
lastUpdate.changed.length === 1 && lastUpdate.changed[0] === player,
"player in changed",
);
// ── Remove component ─────────────────────────────────
world.remove(player, Velocity);
console.assert(!world.has(player, Velocity), "Velocity removed");
const movableAfter = [...world.query(query(Position, Velocity))];
console.assert(movableAfter.length === 0, "no one movable after remove");
// The observer should have emitted {removed: [player]}
const remUpdate = queryLog[queryLog.length - 1];
console.assert(
remUpdate.removed.length === 1 && remUpdate.removed[0] === player,
"player removed from query",
);
// ── Destroy ──────────────────────────────────────────
world.destroy(enemy);
console.assert(!world.isAlive(enemy), "enemy destroyed");
console.assert(world.entityCount === 1, "one entity left");
// ── componentChanged query update ────────────────────
// Add enemy back, observe query(Health).without(Dead)
const enemy2 = world.spawn();
world.add(enemy2, Health, { current: 75 });
const healthLog: QueryUpdate[] = [];
world.observe(query(Health).without(Dead)).subscribe((u) => {
healthLog.push(u);
});
// Enemy won't be in the initial seed yet (subscribe happened after spawn)
// Let's add Dead to trigger the removal
world.add(enemy2, Dead, { timestamp: 123 });
world.flush();
console.assert(
healthLog.some((u) => u.removed[0] === enemy2),
"enemy removed from health-not-dead query after gaining Dead",
);
// ── Entity recycling ─────────────────────────────────
world.destroy(player);
const recycled = world.spawn();
console.assert(recycled !== player, "recycled entity has new generation");
console.assert(world.isAlive(recycled), "recycled entity is alive");
console.assert(!world.isAlive(player), "old handle is dead");
console.log("✅ All smoke tests passed.");

18
tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

10
tsup.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
clean: true,
sourcemap: true,
treeshake: true,
});