Components
Components are plain data containers attached to entities. In @shell/ecs, components are defined with an immutable builder pattern, stored in dense typed arrays per archetype, and accessed through lightweight proxies.
Defining a component
Section titled “Defining a component”Use Define.component() to create a ComponentBuilder. Every builder method returns a new instance — builders are immutable.
import { Define, StoreType } from '@shell/ecs';
export const Transform = Define.component('transform') .withSchema({ x: { type: StoreType.Float32 }, y: { type: StoreType.Float32 }, rotation: { type: StoreType.Float32, default: 0 }, }) .serializable();key— the human-readable name (e.g.'transform'). Not required to be globally unique.withSchema— declares fields and their storage types.serializable— opt into scene save/load. Required forworld.load()to restore this component.
Builder methods
Section titled “Builder methods”| Method | Returns | Description |
|---|---|---|
.withSchema(schema) | new builder | Define the component’s field schema |
.extend(otherBuilder) | new builder | Merge another component’s schema into this one |
.serializable(serialKey?) | new builder | Mark as serializable (required for save/load). Optional serialKey overrides the default key. |
.showInEditor() | new builder | Mark as visible in editor tooling |
.requires(...builders) | new builder | Auto-add missing required components when this one is attached |
.withFieldSerializer(field, serializer) | new builder | Custom serialization logic for a specific field |
Store types
Section titled “Store types”Each schema field declares a StoreType that determines the JavaScript runtime type and the backing storage.
| StoreType | JS Value | Backing Storage | Default |
|---|---|---|---|
StoreType.Int8 | number | Int8Array | 0 |
StoreType.Int16 | number | Int16Array | 0 |
StoreType.Int32 | number | Int32Array | 0 |
StoreType.Float32 | number | Float32Array | 0 |
StoreType.Float64 | number | Float64Array | 0 |
StoreType.Boolean | boolean | Uint8Array | false |
StoreType.String | string | Array<string> | '' |
StoreType.Object | any | Array<any> | {} |
StoreType.Entity | Entity | undefined | index + generation columns | -1 (none) |
StoreType.Instance<T>() | T | Array<T> | null |
StoreType.Prefab<T>() | T | undefined | Array<T> | undefined |
Entity fields store a dense numeric index and generation. Assignment rejects entities from another
registry, and reads resolve only when both values still identify the same active lifecycle. After
despawn or numeric-index reuse, a stale stored reference resolves to undefined rather than the new
entity.
Instance store type
Section titled “Instance store type”For storing class instances or complex objects per entity. Note the function call — Instance<T>() not Instance<T>.
class Sprite { /* ... */}
export const Renderable = Define.component('renderable').withSchema({ sprite: { type: StoreType.Instance<Sprite>(), default: () => new Sprite() }, layer: { type: StoreType.Int32 },});The default for Instance fields accepts:
- A direct value:
default: myInstance - A factory function:
default: () => new Sprite() - A class constructor:
default: Sprite(constructed withnew Sprite(container)wherecontaineris the IoC container)
Prefab store type
Section titled “Prefab store type”Use StoreType.Prefab() to keep a runtime reference to an immutable PrefabBuilder on a component.
Pass the exact prefab type to preserve its named slots when spawning it later:
const EnemyPrefab = Define.prefab().with(Enemy).withChild('weapon', WeaponPrefab);
const Spawner = Define.component('spawner').withSchema({ enemy: { type: StoreType.Prefab<typeof EnemyPrefab>(), default: EnemyPrefab, },});
const prefab = entity.getComponent(Spawner)?.enemy;if (prefab) { commands.spawn(prefab).get('weapon');}Without a type argument, the field accepts any prefab. Prefab fields default to undefined, accept
direct values or zero-argument factories, and reject bundles. They are runtime references and are
not serialized unless the component supplies a custom field serializer.
Field defaults
Section titled “Field defaults”You can provide a default for any field. If omitted, the store type’s built-in default is used.
const Health = Define.component('health').withSchema({ max: { type: StoreType.Float32, default: 100 }, current: { type: StoreType.Float32, default: 100 }, regenRate: { type: StoreType.Float32, default: 1 },});For Instance fields, the default can also be a factory function or class constructor (see above).
Extending components
Section titled “Extending components”Use .extend() to merge another component’s schema into your own. This is useful for building up shared data shapes.
const Position = Define.component('position').withSchema({ x: { type: StoreType.Float32 }, y: { type: StoreType.Float32 },});
const Velocity = Define.component('velocity').withSchema({ vx: { type: StoreType.Float32 }, vy: { type: StoreType.Float32 },});
const MovingBody = Define.component('movingBody').extend(Position).extend(Velocity);The resulting MovingBody schema contains x, y, vx, and vy.
Required components
Section titled “Required components”Use .requires(...) when a component always depends on other components. When the trigger component is added to an entity, missing required components are attached with their schema defaults. Existing components are not overwritten.
export const Sprite = Define.component('sprite') .withSchema({ asset: { type: StoreType.String }, }) .requires(Transform, Visibility);Requirements are recursive and circular-safe because the triggering component is marked present before walking its requirements.
You can also register runtime requirements on the world:
world.registerRequiredComponents(Trigger, Required);Custom field serializers
Section titled “Custom field serializers”For components that need special serialization logic for specific fields (commonly Instance or Object fields):
const MyComponent = Define.component('myComponent') .withSchema({ data: { type: StoreType.Object } }) .serializable() .withFieldSerializer('data', { serialize: (value) => JSON.stringify(value), deserialize: (value) => JSON.parse(value), });Entity accessors
Section titled “Entity accessors”Adding components
Section titled “Adding components”entity.addComponent(Transform, { x: 10, y: 20 });entity.addComponents(Transform, Velocity, Sprite);Checking existence
Section titled “Checking existence”entity.hasComponent(Transform); // booleanGetting a component proxy via $ accessor
Section titled “Getting a component proxy via $ accessor”The preferred way to access components, especially inside systems, is through the $ accessor using the component’s key:
entity.$transform.x; // number (reads from typed array at entity's row)entity.$transform.y = 30; // writes back to typed arrayentity.$<component.key> returns a ComponentProxy — a typed proxy that reads and writes directly into the archetype’s dense storage. Fields are strongly typed based on the component’s schema.
Getting a component proxy via getComponent
Section titled “Getting a component proxy via getComponent”const transform = entity.getComponent(Transform);transform?.x; // number (reads from typed array at entity's row)transform?.y = 30; // writes back to typed arraygetComponent returns the same ComponentProxy as the $ accessor. Use it when you need to pass the component definition as a variable or when you don’t know the component at compile time.
Removing components
Section titled “Removing components”entity.removeComponent(Transform);Component identity
Section titled “Component identity”Each Define.component() call generates a unique Symbol (component.id). This means two packages can both define a component with the same human-readable key (e.g. 'size') without colliding.
component.id— uniqueSymbol, primary runtime key.component.key— human-readable string, used for entity proxy fields (entity.$size).component.serialKey— stable string used for serialization and protocol. Defaults tokey, but can be overridden with.serializable('namespace:key').
Type utilities
Section titled “Type utilities”ComponentFields<C>
Section titled “ComponentFields<C>”Extracts the field types from a component builder as a plain object type:
const Camera = Define.component('camera').withSchema({ x: { type: StoreType.Float64 }, y: { type: StoreType.Float64 },});
type CameraFields = ComponentFields<typeof Camera>;// Result: { x: number, y: number }ComponentRequiring<F>
Section titled “ComponentRequiring<F>”For writing generic functions that accept any component with specific required fields:
import type { ComponentRequiring } from '@shell/ecs';
type PositionLike = ComponentRequiring<{ x: number; y: number }>;
function createMovementSystem<C extends PositionLike>(posComp: C) { const pos = entity.getComponent(posComp)!; pos.x; // number — no cast needed pos.y; // number — no cast needed}Registering components
Section titled “Registering components”Components are registered lazily when first used, but can be pre-registered for deserialization or editor tooling:
world.registerComponent(Transform, Velocity, Texture);Editor visibility
Section titled “Editor visibility”Mark a component as visible in editor tooling:
export const DebugGizmo = Define.component('debugGizmo') .withSchema({ color: { type: StoreType.String } }) .showInEditor();Summary
Section titled “Summary”- Components are immutable builders created with
Define.component(). - Schema fields use
StoreTypefor typed, dense storage. .serializable()is required for save/load..requires()auto-adds dependent components recursively.- Component slots let plugins declare interfaces and apps provide implementations.
- Entity proxies (
$componentandgetComponent) read/write archetype storage directly. - Pre-register components with
world.registerComponent()before deserialization.