Entities & GameComponents
The Object3D is the entity
Section titled “The Object3D is the entity”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.
The GameComponent base class
Section titled “The GameComponent base class”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'.
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’sstatic phase(defaultgameLogic). 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 (anObject3Din 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.
Authored fields: the static schema
Section titled “Authored fields: the static schema”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
Section titled “The ComponentManager”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, wiresnode/world/rigidBody/colliderand queuesinit— every phase tick auto-startsinit()for anything newly attached, so an instance’supdate()can never run before its owninit()has resolved, whether it was attached during scene load or later at runtime; no manualinitAll()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 undernamewhile 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 aconstructor.nameswap independently. On a swap, the stored authored data is re-validated against the new class’sschema: 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 structuredhmr-swap-missconsole warning rather than silently doing nothing — and aNewClassthat isn’t actually aGameComponentsubclass (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’sinit()is still in flight re-runs the new class’sinit()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 component registry
Section titled “The component registry”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.
See also
Section titled “See also”- The game loop & phases — where
update()runs. - Scenes & prefabs — how components are instantiated.
- Foundations: your first GameComponent — a hands-on walkthrough.
- Engine API reference — the generated GameComponent contract.