Skip to content

Entities & GameComponents

There is no separate entity layer in vgai — no ECS, no scene-node wrapper. A Three.js Object3D is the entity. Behavior is attached to it with a GameComponent.

GameComponent (packages/engine/src/ecs/game-component.ts) is an abstract class your behaviors extend:

abstract class GameComponent<K extends AdapterSurface = 'threejs'> {
static phase: SystemPhaseName = 'gameLogic'; // which phase update() runs in
static schema?: z.ZodObject<z.ZodRawShape>; // optional authored fields
static declaredKind: AdapterSurface | 'any' = 'threejs'; // runtime surface marker
// (K is erased at compile time)
node!: NodeOf<K>; // the entity (set on attach);
// THREE.Object3D for the default kind
world!: WorldInstance; // the world this entity lives in
get object3D(): THREE.Object3D; // compat accessor over `node`
rigidBody: BodyOf<K> | null = null; // wired by ComponentManager
collider: ColliderOf<K> | null = null;
init?(ctx: GameContext): void | Promise<void>;
abstract update(dt: number, ctx: GameContext): void;
dispose?(ctx: GameContext): void;
onTriggerEnter?(other: NodeOf<K>, ctx: GameContext): void;
onTriggerExit?(other: NodeOf<K>, ctx: GameContext): void;
}

The surface parameter K generalizes the contract across adapter surfaces (threejs, pixijs — D8/T7.2): NodeOf<K> is the world’s native node type, and node is the entity. For the default kind ('threejs', i.e. every component written as plain class X extends GameComponent), node is a THREE.Object3D and the familiar this.object3D accessor returns exactly the same reference — existing components work unchanged. On a non-threejs instance, object3D throws rather than ever returning a wrong-kind node.

Because K is erased at runtime, a component written for a non-default kind also sets static declaredKind (e.g. 'pixijs') — the ComponentManager checks it at attach and throws loudly on a kind mismatch, and any attach into a react world throws (react entities host no components; they render from game state). A kind-agnostic component — written only against this.node/this.world — declares 'any'.

Lifecycle: init runs once, update runs every tick in its phase, dispose on destroy, with onTriggerEnter/Exit on sensor overlap
The GameComponent lifecycle.
  • init?(ctx) — runs once after the entity is fully constructed (object3D, physics, and joints all exist). May be async.
  • update(dt, ctx) — abstract; runs every fixed tick, in the component’s static phase (default gameLogic). See the game loop & phases.
  • dispose?(ctx) — runs when the entity is destroyed or the component removed.
  • onTriggerEnter? / onTriggerExit? — fire when a sensor collider overlap starts / ends, with the other entity’s node (an Object3D in threejs worlds — trigger partners are always same-world, since collision worlds never span kinds).

State lives on the instance (this). Because hot reload swaps the prototype (not the instance), your component’s live state survives a code edit.

A component can declare a static Zod schema describing the fields an author can set in a scene file. Those fields are validated and assigned onto the instance when the scene loads — and they drive the editor’s inspector. (See Scenes & prefabs and the Scene Schema reference.)

The ComponentManager (packages/engine/src/ecs/component-manager.ts) owns every live component instance, indexed by phase (for ticking) and by Object3D (for lookup). It:

  • registers one tick function per phase into the SystemRunner’s dedicated component-tick slot (setComponentTick) — within every phase, the tick runs after all engine systems and before all game-registered callbacks, as a structural guarantee rather than an accident of registration order;
  • on attach, wires node / world / rigidBody / collider and queues init — every phase tick auto-starts init() for anything newly attached, so an instance’s update() can never run before its own init() has resolved, whether it was attached during scene load or later at runtime; no manual initAll() call is required for this guarantee;
  • exposes initAll() — the scene loader calls it once after the whole scene has loaded, to await any in-flight inits synchronously (useful when later code depends on their side effects having already completed);
  • supports hotSwap(name, NewClass) — updating the prototype of every live instance registered under name while preserving their state (the basis for hot reload). Matching is by the registry name recorded at attach, not the class’s runtime name — so it survives minification and class renames, and two classes that happen to share a constructor.name swap independently. On a swap, the stored authored data is re-validated against the new class’s schema: newly added fields get their defaults on the live instance, fields you’ve mutated at runtime keep their values, and a failed re-parse keeps the last-good fields. A swap that matches nothing (or a failed re-parse) emits a structured hmr-swap-miss console warning rather than silently doing nothing — and a NewClass that isn’t actually a GameComponent subclass (say, a plain helper function in the reloaded module that happens to share the registry name) is refused with a loud warning, no live instance touched. A swap that lands while an instance’s init() is still in flight re-runs the new class’s init() exactly once, after the old one settles.

It is safe to attach() / detach() from inside an update() (a Unity-MonoBehaviour-style spawn/despawn pattern): structural changes are deferred and applied between ticks, and a component detached earlier in a frame is not update()’d again that frame.

The registry maps component names (strings used in scene files) to their classes. applyComponents (packages/engine/src/scene/component-registry.ts) hydrates an entity’s components from scene JSON: it looks up each class, validates/defaults the data through the class’s schema.parse(), assigns it onto a fresh instance, and attaches it.

Unknown component names throw at load time (fail-fast). npm run validate-scenes checks every .vscn.json against the registry offline.