Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ node packages/game-benchmark/src/index.ts --no-render -g boids -d 10 -t 10 -l ob

**Archetype** (`packages/objecs/src/archetype.ts`): A query that groups entities sharing the same component combination. Created via `world.archetype('component1', 'component2', ...)`. Supports `without()` for exclusion filters.

**Entity**: Plain JavaScript objects (must be `JsonObject` from type-fest). Components are simply properties on the entity object.
**Entity**: Plain JavaScript objects (must extend `EntityBase` — restricts components to JSON-compatible values, no functions/symbols). Components are simply properties on the entity object.

### Usage Pattern

Expand Down
5 changes: 0 additions & 5 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,6 @@ export default defineConfig(
"Use `entity[prop] !== undefined` instead of Object.hasOwn() in core library. Object.hasOwn is ~10% slower due to static method call overhead. See CLAUDE.md Performance Conventions.",
},
],
// ECS entities use JsonObject index signature ({[Key in string]: JsonValue})
// so TS thinks every property access returns JsonValue, never undefined.
// At runtime, missing components ARE undefined — this check is the core
// archetype matching mechanism.
"@typescript-eslint/no-unnecessary-condition": "off",
},
},
{
Expand Down
2 changes: 1 addition & 1 deletion packages/objecs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ A lightweight, type-safe Entity Component System (ECS) for TypeScript game devel

- **Simple API** - Entities are plain JavaScript objects, components are just properties
- **Type-safe** - Full TypeScript support with inferred types for queries
- **Zero dependencies** (only `type-fest` for utility types)
- **Zero dependencies**
- **Small footprint** - ~2KB minified
- **Archetype-based queries** - Efficient entity filtering with component matching
- **Reactive archetypes** - Queries automatically update when entities change
Expand Down
4 changes: 1 addition & 3 deletions packages/objecs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,5 @@
"vite": "7.3.1",
"vitest": "4.0.17"
},
"dependencies": {
"type-fest": "5.4.0"
}
"dependencies": {}
}
10 changes: 2 additions & 8 deletions packages/objecs/src/archetype.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
import { JsonObject } from "type-fest";
import { EntityCollection, ReadonlyEntityCollection, World } from "./world.js";

type SafeEntity<
Entity extends JsonObject,
Components extends keyof Entity,
> = Entity & Required<Pick<Entity, Components>>;
import { type EntityBase, EntityCollection, type ReadonlyEntityCollection, type SafeEntity, World } from "./world.js";

/**
* An archetype is a collection of entities that share the same components.
* Archetypes should not be constructed directly, but rather through the
* `World` class using the `archetype` method.
*/
export class Archetype<
Entity extends JsonObject,
Entity extends EntityBase,
Components extends Array<keyof Entity>,
> {
#entities: EntityCollection<SafeEntity<Entity, Components[number]>>;
Expand Down
35 changes: 34 additions & 1 deletion packages/objecs/src/world.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import { ReadonlyEntityCollection, World } from "./world.js";
import { type EntityBase, ReadonlyEntityCollection, World } from "./world.js";

type Entity = {
color?: string;
Expand Down Expand Up @@ -320,4 +320,37 @@ describe("World", () => {
renderSystem();
});
});

describe("EntityBase type constraint", () => {
it("should reject entities with function components at the type level", () => {
type InvalidEntity = {
callback?: () => void;
};

// @ts-expect-error — functions are not assignable to ComponentValue
new World<InvalidEntity>();
});

it("should reject entities with symbol components at the type level", () => {
type InvalidEntity = {
id?: symbol;
};

// @ts-expect-error — symbols are not assignable to ComponentValue
new World<InvalidEntity>();
});

it("should accept entities with valid JSON-compatible components", () => {
type ValidEntity = {
name?: string;
health?: number;
active?: boolean;
position?: { x: number; y: number };
tags?: string[];
metadata?: null;
};

expectTypeOf<ValidEntity>().toExtend<EntityBase>();
});
});
});
23 changes: 20 additions & 3 deletions packages/objecs/src/world.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
import { JsonObject } from "type-fest";
import { Archetype } from "./archetype.js";

/**
* Allowed component value types. Prevents functions, symbols, and other
* non-serializable values from being used as component data.
*/
export type ComponentValue =
| string
| number
| boolean
| null
| ComponentValue[]
| { [key: string]: ComponentValue };

/**
* Base constraint for entity types. All entity properties must be
* JSON-compatible component values or undefined (for optional components).
*/
export type EntityBase = Record<string, ComponentValue | undefined>;

export type SafeEntity<
Entity extends JsonObject,
Entity extends EntityBase,
Components extends keyof Entity,
> = Entity & Required<Pick<Entity, Components>>;

Expand Down Expand Up @@ -147,7 +164,7 @@ export class EntityCollection<T> implements ReadonlyEntityCollection<T> {
/**
* Container for Entities
*/
export class World<Entity extends JsonObject> {
export class World<Entity extends EntityBase> {
#archetypes = new Set<Archetype<Entity, Array<keyof Entity>>>();
#entities = new EntityCollection<Entity>();
#componentIndex = new Map<keyof Entity, Set<Archetype<Entity, Array<keyof Entity>>>>();
Expand Down
4 changes: 0 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.