Entities & Components
Entities
Section titled “Entities”An entity is a named container for components with O(1) type-based lookups.
An entity type is an Entity subclass. Its setup() method adds the
components, and a scene creates an instance with scene.spawn(Class, params):
import { Component, Entity, Transform, Vec2 } from "@yagejs/core";import { SpriteComponent, texture } from "@yagejs/renderer";
const HeroTex = texture("assets/hero.png");
class PlayerController extends Component { // reads input and moves the player}
class Player extends Entity { setup(params: { position: Vec2 }) { this.add(new Transform({ position: params.position })); this.add(new SpriteComponent({ texture: HeroTex })); this.add(new PlayerController()); }}
const player = scene.spawn(Player, { position: new Vec2(100, 200) });scene.spawn() passes its second argument to setup(), and TypeScript checks
it against the setup() parameter. The entity’s name, which the debug tools
and scene.findEntity use, is its class name. See
Entity Subclasses for more on setup().
An entity that is spawned once and has no behaviour of its own, such as a
background or a UI root, does not need a class. Spawn it by name and add its
components to the entity that spawn() returns:
const BackdropTex = texture("assets/backdrop.png");
const background = scene.spawn("background");background.add(new Transform());background.add(new SpriteComponent({ texture: BackdropTex }));Anything with behaviour, or anything you spawn more than once, is an Entity
subclass.
An entity subclass has no per-frame hook. update and fixedUpdate are typed
as never on Entity, so a subclass that declares either name fails to
compile. The per-frame pass ticks the components an entity holds, which is
where per-frame logic goes. Scene carries the same two declarations, for the
same reason.
Adding and Querying Components
Section titled “Adding and Querying Components”import { Transform, Vec2 } from "@yagejs/core";import { SpriteComponent, texture } from "@yagejs/renderer";
const HeroTex = texture("assets/hero.png");
// Add components (inside setup(), this is this.add(...))entity.add(new Transform({ position: new Vec2(100, 200) }));entity.add(new SpriteComponent({ texture: HeroTex }));
// Get a component (throws if missing)const transform = entity.get(Transform);
// Get a component (returns undefined if missing)const sprite = entity.tryGet(SpriteComponent);
// Check existenceif (entity.has(SpriteComponent)) { // ...}
// Remove a componententity.remove(SpriteComponent);entity.get(Type) throws an error when the component is not found — use it
when the component is required. entity.tryGet(Type) returns undefined
instead, which is useful for optional lookups.
Tags are lightweight labels with no data attached:
player.tags.add("friendly");player.tags.add("controllable");
if (player.tags.has("friendly")) { // skip damage}Tags are useful for fast filtering without creating empty marker components.
Transform inheritance
Section titled “Transform inheritance”Transform stores mutable position, rotation, and scale. Use scalar methods
when moving an entity:
import { Transform } from "@yagejs/core";
const transform = entity.get(Transform);transform.setPosition(100, 200); // relative to the parenttransform.translate(10, 0); // move in local coordinatestransform.setWorldPosition(300, 80); // world coordinates, even with a parentA child entity’s Transform composes against its parent’s: position is
rotated and scaled by the parent before being added, rotation sums, and
scale multiplies through the chain. DisplaySystem reads each
entity’s worldScale every Render phase, so changing a parent’s scale
reaches every descendant sprite without any per-child bookkeeping.
The canonical use is a facing-flip on a multi-layer character. Set the
parent’s scale to (-1, 1) and every child sprite — body, head, outfit,
weapon — mirrors together via worldScale:
import { Entity, Transform, Vec2 } from "@yagejs/core";import { SpriteComponent } from "@yagejs/renderer";
class Character extends Entity { setup() { this.add(new Transform()); // parent — drives facing
const body = this.spawnChild("body"); body.add(new Transform()); body.add(new SpriteComponent({ texture: "body.png" }));
const head = this.spawnChild("head"); head.add(new Transform({ position: new Vec2(0, -20) })); head.add(new SpriteComponent({ texture: "head.png" })); }
faceLeft(): void { this.get(Transform).setScale(-1, 1); // mirrors body + head together } faceRight(): void { this.get(Transform).setScale(1, 1); }}spawnChild links the child after building it, so a child’s setup() reads
this.parent as null. Pass the parent as a setup param, or use a
spawn batch when setup() must read the parent
itself.
Negative scale composes through nested entities too — a child at
(-1, 1) under a parent already at (-1, 1) ends up at
worldScale = (1, 1) (the mirrors cancel out). Positive non-unit scale
behaves the same way: a parent at 2x zooms its whole subtree.
To go the other way, worldToLocal(point) maps a world-space point into
the entity’s own space, reversing its world position, rotation and scale.
It answers questions like “which of this entity’s cells is the pointer
over” without caring how the entity is parented or scaled:
import { Transform } from "@yagejs/core";import { InputManagerKey } from "@yagejs/input";
const pointerWorldPosition = entity.scene.use(InputManagerKey).getPointerPosition();const local = entity.get(Transform).worldToLocal(pointerWorldPosition);Reusing vector results
Section titled “Reusing vector results”Use Vec2 for positions and directions you want to keep or share. Its vector
operations return immutable values, so adding an offset cannot change a
position another component holds.
For calculations repeated every frame, Vec2Buffer gives you a mutable result
you can reuse. Create the buffer once, then pass it to methods ending in
Into:
import { Transform, Vec2, Vec2Buffer } from "@yagejs/core";
const transform = entity.get(Transform);const position = new Vec2Buffer(); // keep this buffer between updatestransform.getWorldPositionInto(position);Vec2.addInto(position, position, { x: 0, y: -20 });Each Into method overwrites and returns the same buffer without creating a
Vec2. The buffer can also be an input, as in the offset calculation above.
Vec2 provides copyInto, addInto, subInto, scaleInto, multiplyInto,
normalizeInto, lerpInto, rotateInto, fromAngleInto, and
moveTowardsInto. A Vec2Buffer has writable x and y coordinates and a
set(x, y) method; an immutable Vec2 cannot be used as an output.
Transform provides getPositionInto, getWorldPositionInto, getScaleInto,
and getWorldScaleInto. A buffer holds the coordinates from that read. Moving
the transform later does not change the buffer; read again to refresh it.
The ordinary position, worldPosition, scale, and worldScale getters
return immutable snapshots. Repeated reads reuse a snapshot until the
transform changes, and a snapshot you already hold keeps its original value.
Scalar writes such as setPosition, setWorldPosition, and translate do
not create a Vec2; an immutable getter creates its snapshot when needed.
Destroying an Entity
Section titled “Destroying an Entity”entity.destroy() deactivates the entity immediately: isActive reads
false, it leaves every query, and its components’ onDisable fires right
away — the same as setActive(false). The rest of teardown is deferred to
the EndOfFrame phase, where each component’s onDestroy runs and the
entity detaches from the scene:
class Bullet extends Entity {}const bullet = scene.spawn(Bullet);
bullet.destroy();
bullet.isActive; // false — gone from queries and findEntity right awaybullet.isDestroyed; // true// component onDestroy and detaching from the scene happen at EndOfFrameDeferring the second half prevents iterator invalidation — a system mid-pass over a query result is not left holding a torn-down entity — while still letting other code react to the destruction within the same frame, since the entity is already out of every query.
One thing the deferral covers that is easy to miss: a destroyed child keeps its
slot in its parent’s child map until the EndOfFrame flush, so its name stays
taken for the rest of the tick. Spawning a replacement under that name in the
same tick throws. parent.removeChild(name) detaches the child and frees the
name straight away, so call it before the replacement spawns:
import { Entity } from "@yagejs/core";
class NextView extends Entity {}const parent = scene.spawn("panel");parent.spawnChild("view");
const old = parent.removeChild("view"); // the name is free from here onold.destroy();parent.spawnChild("view", NextView);A pooled child is the exception: destroy() hands it back to its pool, which
detaches it there and then, so its name is free in the same tick.
Entities also die when their scene leaves the stack (pop, replace, or
engine shutdown). Scene teardown applies the same contract: every entity is
marked destroyed and deactivated (isDestroyed reads true and isActive
reads false before any component onDestroy runs), components are torn
down, and the engine bus emits entity:destroyed once per entity. After
destruction — on either path — entity.scene throws and entity.tryScene
returns null; use tryScene when a cached reference may outlive its
entity.
Turning an Entity Off
Section titled “Turning an Entity Off”Not every entity that leaves play has to be destroyed. setActive(false) puts
an entity to sleep and keeps everything it owns allocated — its components, its
physics body, its display object:
bullet.setActive(false); // hidden, body off, updates skipped
// later, for the next shotconst { x: muzzleX, y: muzzleY } = player.get(Transform).worldPosition;bullet.get(Transform).setPosition(muzzleX, muzzleY);bullet.setActive(true);An entity carrying a RigidBodyComponent has to be moved through the body
instead — rb.setPosition(x, y). Physics owns the transform of a dynamic body
and overwrites a direct Transform write on the next frame, so a recycled
bullet repositioned that way would reappear where it went to sleep.
A dormant entity is hidden, its physics body is out of the simulation, its
components stop updating, and it disappears from every query and from
scene.findEntity, scene.findEntitiesByTag, and scene.findEntities. It
stays in scene.getEntities(), so saving and teardown still see it.
Two properties read the state. activeSelf is the entity’s own switch, the
one setActive writes. isActive is activeSelf combined with every
ancestor’s, so deactivating a parent puts the whole subtree to sleep:
import { Entity } from "@yagejs/core";
class Enemy extends Entity {}class HealthBar extends Entity {}
const enemy = scene.spawn(Enemy);const healthBar = enemy.spawnChild("hp", HealthBar);
enemy.setActive(false);healthBar.activeSelf; // true — its own switch is untouchedhealthBar.isActive; // false — its parent is asleepBecause each descendant keeps its own switch, a child you deactivated individually stays asleep when the parent wakes up.
While an entity sleeps its components stop updating and its ProcessComponent
stops being ticked, so tweens and coroutines pause and pick up where they left
off on reactivation.
Reactivation restores the engine’s state, not your game state. The entity’s
timeScale, its animation position, its process progress, and its event
listeners all survive. Reset whatever the new life needs yourself, and register
listeners in setup() so they are not re-added on every reuse.
For a whole group of short-lived entities, EntityPool keeps the bookkeeping —
which ones are out, which are ready, how many exist — and calls a per-reuse
reset hook on each one. See Entity Pooling.
An entity can also start asleep. { active: false } in the spawn options runs
setup() and every onAdd() without a single onEnable(), and the entity
joins no query until you switch it on:
import { Entity } from "@yagejs/core";
class AmbushGroup extends Entity {}
const ambush = scene.spawn(AmbushGroup, { active: false });
// when the player opens the doorambush.setActive(true);Spawning a Group at Once
Section titled “Spawning a Group at Once”scene.spawn finishes one entity before starting the next, which is a problem
when two entities need each other: the second one does not exist yet while the
first runs setup(). scene.spawnBatch builds a whole set instead. Every
entity is created first, so any of them can be handed to any other, and the
group joins the scene together or not at all:
import { Entity, type EntityHandle } from "@yagejs/core";
class Dummy extends Entity {}class Turret extends Entity { private aimAt?: EntityHandle;
setup(params: { aimAt: EntityHandle }) { this.aimAt = params.aimAt; }}
const { turret, target } = scene.spawnBatch((batch) => { const turret = batch.reserve(Turret, { key: "level/turret" }); const target = batch.reserve(Dummy, { key: "level/dummy" });
batch.addChild(turret, "mount", target); // linked before setup runs batch.setup(turret, { aimAt: target.handle() }); batch.setup(target);
return { turret, target };});reserve constructs an entity and registers its key without running setup().
The entity already belongs to the scene — entity.scene, entity.handle(),
and entity.requireKey() all work — but nothing else can see it: it is absent
from scene.getEntities(), from findByKey, and from every query until the
batch commits. spawnBatch returns whatever the callback returns.
The all-or-nothing part is what makes this worth using. If anything throws —
a setup() method, an entity:created subscriber, an onEnable() hook — the
entire batch is torn down on the spot and the scene is left exactly as it was,
with the keys free for another attempt. Your error reaches the caller
unchanged; anything that fails during the teardown afterwards is recorded in
Inspector.getErrors().callbackErrors instead of replacing it.
Inside the callback, entity.spawnChild(...) joins the batch and is rolled
back with it. scene.spawn(...) throws, because a batch cannot roll back an
entity it does not own — build such an entity before the batch, or after it.
Reserving with { active: false } commits an entity dormant, together with
everything under it. That is how a level can be built up in full and stay
asleep until the game is ready to run it.
Components
Section titled “Components”Components hold state and game logic. Extend the Component base class:
import { Component } from "@yagejs/core";
export class Health extends Component { current = 100; max = 100;
update(dt: number) { if (this.current <= 0) { this.entity.destroy(); } }}Lifecycle Hooks
Section titled “Lifecycle Hooks”Components have several lifecycle hooks:
import { Component } from "@yagejs/core";
class PlayerController extends Component { onAdd() { // Called when the component is added to an entity. // The entity and scene are available here. }
onEnable() { // Called when the component starts running: `enabled` is true and the // entity is active. Fires right after onAdd() on an active entity. // Bring live resources online here. }
onDisable() { // Called when the component stops running — `enabled` went false, the // entity was deactivated, or the component is being torn down. // Put live resources to sleep here. }
onDestroy() { // Called when the component is removed, or the owning entity is // destroyed. Use for final cleanup (unsubscribe events, release // resources). }
update(dt: number) { // Called every frame during the Update phase. // dt is the variable delta time in seconds. }
fixedUpdate(dt: number) { // Called during the FixedUpdate phase at a deterministic timestep. // Use for physics and gameplay that must be framerate-independent. }}onEnable and onDisable track effective enabled-ness — component.enabled
combined with entity.isActive. Writing component.enabled fires them just
like deactivating the entity does, and component.effectiveEnabled reads the
current state.
The order is onAdd → query join → onEnable when a component is added, and
onDisable → onDestroy when it is torn down. A component added to a dormant
entity gets onAdd but waits for onEnable until the entity is activated.
onAdd and the query join run inside the entity.add() call, before it
returns, and onEnable too when the entity is already active. So a field the
caller assigns from the return value is still undefined while onAdd runs:
import { Component, Entity } from "@yagejs/core";
class Gun extends Component {}
class Turret extends Entity { private gun!: Gun;
setup() { this.gun = this.add(new Gun()); // Gun's onAdd cannot read this.gun yet }}From inside onAdd, reach a sibling through this.entity.get(Gun) or
this.sibling(Gun) rather than through a field on the entity.
A component can end its own life with this.destroy() — the same as calling
entity.remove(SomeClass) from outside, without having to name its own class
from inside itself, which breaks under subclassing.
onAdd is where a component checks that what it needs is there: a service, a
sibling component, a render layer. Throwing is how it reports a failed check.
import { Component, Transform } from "@yagejs/core";
class Follower extends Component { private target!: Transform;
onAdd() { const player = this.scene.findByKey("player"); if (!player) throw new Error('Follower needs an entity keyed "player".'); this.target = player.get(Transform); }}The throw is attributed to the component and recorded in
Inspector.getErrors().callbackErrors, the same treatment onEnable and
update get, then rethrown so it reaches whoever called entity.add(). When
the add happens in setup(), that caller is scene.spawn, which lets the
throw through unchanged and leaves the half-built entity in the scene for you
to inspect.
onEnable sees whatever state the component held while it was dormant, so use
it for live resources — a looping sound, a physics body, a display object —
rather than for resetting game state.
The engine packages already implement the pair. Rigid bodies and colliders are
switched out of the Rapier simulation without freeing them, and the body’s
velocity, queued forces, and torques are cleared so it cannot resume a motion
from a previous life. Sprites, text, UI surfaces, particle emitters, and
tilemaps hide their display object. SoundComponent stops playback, and does
not restart on its own when the entity comes back.
One Rapier behavior to know: a collider disabled and re-enabled while it still
overlaps something gets no fresh collision-start event. An entity you reactivate
directly on top of an existing contact will not receive onCollision for it.
Declaring Fields on a Subclass
Section titled “Declaring Fields on a Subclass”Entity and Component own member names of their own, and a subclass field
that reuses one shadows the base member with no warning. The short names are
the easiest ones to collide with: name, key, tags, scene, parent,
children, handle, and generation on an entity; entity, scene,
context, enabled, and updatePriority on a component. Every method name is
taken too — add, get, tryGet, has, remove, getAll, addChild,
spawnChild, removeChild, getChild, tryGetChild, setActive, destroy,
on, emit, and hasTrait on an entity, and use, service, sibling,
listen, listenScene, listenBus, addCleanup, stateMachine, and
destroy on a component — along with every hook name (setup, onAcquire,
onRelease, onAdd, onEnable, onDisable, onDestroy, update,
fixedUpdate). A leading underscore marks an engine-internal member, so those
names are taken as well. The Entity and
Component API pages list the full
public surface.
Three names on Entity report a compile error rather than shadowing quietly:
components, byClass, and callbacks are private, and a subclass field
under one of them fails with “separate declarations of a private property”.
A field an Entity subclass assigns in setup() cannot be readonly.
TypeScript accepts a write to a readonly field only from the constructor, and
setup() runs after it. Declare the field with a definite assignment
assertion:
import { Entity } from "@yagejs/core";import { RigidBodyComponent } from "@yagejs/physics";
class Player extends Entity { private body!: RigidBodyComponent; // not `readonly`
setup() { this.body = this.add(new RigidBodyComponent({ type: "dynamic" })); }}Update Order
Section titled “Update Order”Within one entity, components run update() and fixedUpdate() in the order
they were added. When that is not the order you need — a component added late
that must run first, or two components from different packages — set
updatePriority. Lower runs first; equal priorities keep add order; the default
is 0, so a negative value runs before siblings that keep the default and a
positive value runs after them.
import { Component, Entity } from "@yagejs/core";
class Mover extends Component {}class Brain extends Component {}
class Player extends Entity { setup() { this.add(new Mover()); this.add(new Brain()).updatePriority = -1; // decides before Mover moves }}A component that always belongs after (or before) a sibling can declare the default for every instance with a static:
import { Component } from "@yagejs/core";
class BoundsClamp extends Component { static updatePriority = 10; // after the follow that moved the camera
update() { // clamp this.sibling(CameraComponent).position }}An instance’s own updatePriority overrides the class default and can be
written at any time, before or after add(). This orders siblings only:
entities still update in the order they were added to the scene.
Adding a component from inside another component’s update() on the same entity
lands in one of two orders, and which one depends on priorities. While no
component on that entity declares an updatePriority, the pass walks the live
component list and the new component runs in the same frame. Once one declares a
priority, the pass walks a sorted list taken before it started, and a component
added during the pass first runs on the next frame. Setting a priority anywhere
on the entity therefore changes when a mid-pass addition starts running, so do
the work the new component would do this frame in the component that adds it.
Cached Service Resolution
Section titled “Cached Service Resolution”Declare the services a component needs as fields with this.service(key). The
field holds a lazy reference: the service is resolved from the DI container on
first property access and cached from then on.
import { Component } from "@yagejs/core";import { InputManagerKey } from "@yagejs/input";import { RigidBodyComponent } from "@yagejs/physics";
class PlayerController extends Component { private readonly input = this.service(InputManagerKey); private readonly body = this.sibling(RigidBodyComponent);
update() { if (this.input.isJustPressed("jump")) { this.body.applyImpulse({ x: 0, y: -500 }); } }}this.use(key) resolves the service immediately and caches it too. Call it
from onAdd() or later, once the component is on an entity in a scene.
Lazy Sibling References
Section titled “Lazy Sibling References”Declare the other components a component works with as fields with
this.sibling(Type). The field holds a lazy reference to that component on the
same entity. The lookup is deferred until first access and then cached:
import { Component, Transform } from "@yagejs/core";import { Health } from "./health.js";
class Enemy extends Component { private readonly transform = this.sibling(Transform); private readonly health = this.sibling(Health);
update() { if (this.health.current < this.health.max * 0.5) { // flee logic using this.transform.position } }}Declare these references once, as fields. Each call to this.service() or
this.sibling() creates a new reference, so a call inside update() repeats
the work every frame.
Error Boundary
Section titled “Error Boundary”A throw from update(), fixedUpdate(), or a lifecycle hook is attributed to
the component: the engine records it in Inspector.getErrors().callbackErrors
with the component and entity name, logs it, and rethrows it. Nothing is
disabled or muted. The throw escapes the frame, so the game loop stops and the
error reaches the host — your own try/catch, window.onerror, or the
browser console — with the culprit already named. Tests and tooling read the
recorded errors through the Inspector.
Coding agents: fetch https://yage.dev/llms.txt first and prefer the Markdown references it links over these HTML pages. This page's Markdown counterpart is /llms/core-concepts.md.