Skip to content

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.


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 for world.load() to restore this component.
MethodReturnsDescription
.withSchema(schema)new builderDefine the component’s field schema
.extend(otherBuilder)new builderMerge another component’s schema into this one
.serializable(serialKey?)new builderMark as serializable (required for save/load). Optional serialKey overrides the default key.
.showInEditor()new builderMark as visible in editor tooling
.requires(...builders)new builderAuto-add missing required components when this one is attached
.withFieldSerializer(field, serializer)new builderCustom serialization logic for a specific field

Each schema field declares a StoreType that determines the JavaScript runtime type and the backing storage.

StoreTypeJS ValueBacking StorageDefault
StoreType.Int8numberInt8Array0
StoreType.Int16numberInt16Array0
StoreType.Int32numberInt32Array0
StoreType.Float32numberFloat32Array0
StoreType.Float64numberFloat64Array0
StoreType.BooleanbooleanUint8Arrayfalse
StoreType.StringstringArray<string>''
StoreType.ObjectanyArray<any>{}
StoreType.EntityEntity | undefinedindex + generation columns-1 (none)
StoreType.Instance<T>()TArray<T>null
StoreType.Prefab<T>()T | undefinedArray<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.


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 with new Sprite(container) where container is the IoC container)

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.


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).


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.


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);

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.addComponent(Transform, { x: 10, y: 20 });
entity.addComponents(Transform, Velocity, Sprite);
entity.hasComponent(Transform); // boolean

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 array

entity.$<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 array

getComponent 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.

entity.removeComponent(Transform);

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 — unique Symbol, 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 to key, but can be overridden with .serializable('namespace:key').

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 }

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
}

Components are registered lazily when first used, but can be pre-registered for deserialization or editor tooling:

world.registerComponent(Transform, Velocity, Texture);

Mark a component as visible in editor tooling:

export const DebugGizmo = Define.component('debugGizmo')
.withSchema({ color: { type: StoreType.String } })
.showInEditor();

  • Components are immutable builders created with Define.component().
  • Schema fields use StoreType for 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 ($component and getComponent) read/write archetype storage directly.
  • Pre-register components with world.registerComponent() before deserialization.