Entity Subclasses
An entity type in YAGE is an Entity subclass. The subclass gets a typed
setup() method for initialization and works naturally with TypeScript’s type
system.
A named spawn, such as scene.spawn("background") followed by .add(...), is
only for a one-off entity: one that is spawned once and has no behaviour of its
own. Anything spawned more than once, or with behaviour, is a subclass.
Basic Subclass
Section titled “Basic Subclass”import { Component, Entity, ProcessComponent, Transform, Vec2 } from "@yagejs/core";import { SpriteComponent, type TextureHandle } from "@yagejs/renderer";
interface PlayerParams { position: Vec2; texture: TextureHandle;}
class Player extends Entity { setup(params: PlayerParams) { this.add(new Transform({ position: params.position })); this.add(new SpriteComponent({ texture: params.texture })); this.add(new ProcessComponent()); this.add(new Health(100)); // a component of your game }}
// Defined elsewhere in your game:declare class Health extends Component { constructor(max: number) }Spawn it from a scene:
import { texture } from "@yagejs/renderer";
const HeroTex = texture("assets/hero.png");
const player = scene.spawn(Player, { position: new Vec2(100, 200), texture: HeroTex,});scene.spawn() reads the params type from the setup() parameter, so you get
full autocomplete and type checking. If you omit a required setup() field,
the error names the field that’s actually missing (Property 'texture' is missing) rather than blaming an unrelated property.
An entity subclass assembles components. The game logic goes in those
components: update(), fixedUpdate(), event handlers, and methods. An
Entity subclass cannot declare update() or fixedUpdate(); both are typed
never.
Why setup() Instead of constructor
Section titled “Why setup() Instead of constructor”The constructor runs before the entity is attached to the scene’s ECS world.
At that point the entity has no scene reference, no access to engine services,
and components cannot query siblings.
setup() is called after the entity is registered with the scene, so you
can safely:
- Resolve services (
this.scene.use(...)) - Query the scene for other entities
- Emit events that other entities can receive
- Add components that depend on the scene context
If setup() throws, the error reaches the scene.spawn() caller unchanged and
the entity stays in the scene, holding the components it managed to add and any
children it already spawned. It is left there to be inspected. Use
scene.spawnBatch when a half-built entity would be worse than none; a batch
discards everything it reserved.
import { Entity, Transform, type Vec2 } from "@yagejs/core";import { ColliderComponent, RigidBodyComponent } from "@yagejs/physics";
class Enemy extends Entity { setup(params: { position: Vec2 }) { // Safe — the entity is attached to the scene, so scene-dependent // components (physics body, collider) can be added here. this.add(new Transform({ position: params.position })); this.add(new RigidBodyComponent({ type: "dynamic" })); this.add(new ColliderComponent({ shape: { type: "circle", radius: 16 } })); }}One thing setup() cannot do is reach its parent. parent.spawnChild(name, Class, params) builds the child first and links it afterwards, so
this.parent is null for the whole of setup(), including the onAdd() of
every component that setup adds. The link exists as soon as spawnChild
returns.
Pass the parent in as a setup param when the child needs it. The param also
types it as the concrete parent class, which this.parent (typed
Entity | null) does not:
import { Entity, Transform, Vec2 } from "@yagejs/core";
class Turret extends Entity { setup() { this.add(new Transform()); this.spawnChild("barrel", Barrel, { owner: this }); }}
class Barrel extends Entity { private owner!: Turret;
setup({ owner }: { owner: Turret }) { this.owner = owner; // usable immediately this.add(new Transform({ position: new Vec2(0, -8) })); }}When setup() has to read this.parent itself, build the pair in a
spawn batch:
batch.addChild links two reserved entities before batch.setup runs either
one.
An entity subclass exposes only this.scene — there is no use(),
service(), or context on the entity itself. To reach a service from
setup() or any method, go through the scene:
import { Entity, RandomKey } from "@yagejs/core";import { AudioManagerKey } from "@yagejs/audio";
class Crate extends Entity { setup() { const rng = this.scene.use(RandomKey); // scene scope, then engine; throws if missing const audio = this.scene.context.tryResolve(AudioManagerKey); // undefined if missing }}this.scene.use(key) checks scene scope first and engine scope second, so
per-scene infrastructure (the physics world, the render tree, the scene RNG)
and engine-wide services both resolve through the same call. Component.use
caches its result and Scene.use does not, so a lookup that runs every frame
belongs in a component.
Traits
Section titled “Traits”Traits define capabilities that cut across the class hierarchy. They are a lightweight alternative to interfaces that work at runtime — you can check whether an entity has a trait without knowing its concrete class.
Defining a Trait
Section titled “Defining a Trait”import { defineTrait } from "@yagejs/core";
const Interactable = defineTrait<{ interact(): void }>("Interactable");const Damageable = defineTrait<{ takeDamage(amount: number): void }>("Damageable");The type parameter lists the members an entity with the trait must have.
Applying Traits
Section titled “Applying Traits”Use the @trait() decorator on entity subclasses. The class must declare
every member the trait lists, or it fails to compile:
import { Component, Entity, Transform, Vec2, trait } from "@yagejs/core";import { SpriteComponent, texture } from "@yagejs/renderer";
const ChestTex = texture("assets/chest.png");const OpenChestTex = texture("assets/chest-open.png");
// The rule for opening a chest lives in a component.class ChestLid extends Component { private readonly sprite = this.sibling(SpriteComponent); private readonly transform = this.sibling(Transform); private opened = false;
open() { if (this.opened) return; this.opened = true; this.sprite.setTexture(OpenChestTex); this.scene.spawn(Loot, { position: this.transform.position }); }}
@trait(Interactable)class Chest extends Entity { setup(params: { position: Vec2 }) { this.add(new Transform({ position: params.position })); this.add(new SpriteComponent({ texture: ChestTex })); this.add(new ChestLid()); }
interact() { this.get(ChestLid).open(); }}
@trait(Interactable)@trait(Damageable)class NPC extends Entity { setup(params: { position: Vec2 }) { this.add(new Transform({ position: params.position })); this.add(new Health(50)); this.add(new Conversation()); }
interact() { this.get(Conversation).start(); }
takeDamage(amount: number) { this.get(Health).damage(amount); }}
// Defined elsewhere in your game:declare class Loot extends Entity { setup(params: { position: Vec2 }): void }declare class Health extends Component { constructor(max: number); damage(amount: number): void }declare class Conversation extends Component { start(): void }Each trait method hands the call to a component of the entity, and the component holds the rule.
Querying by Trait
Section titled “Querying by Trait”Use entity.hasTrait() as a type guard to discover capabilities at runtime.
This component, on the player, interacts with everything in reach when the
interact action is pressed:
import { InputManagerKey } from "@yagejs/input";
class Interactor extends Component { private readonly input = this.service(InputManagerKey); private readonly transform = this.sibling(Transform); private readonly reach = 48;
update() { if (!this.input.isJustPressed("interact")) return; const here = this.transform.position; const nearby = this.scene.findEntities({ filter: (e) => (e.tryGet(Transform)?.position.distance(here) ?? Infinity) <= this.reach, });
for (const entity of nearby) { if (entity.hasTrait(Interactable)) { entity.interact(); // TypeScript knows this method exists } } }}Use entityClassHasTrait() when code must validate a class before it creates
an instance. The check includes traits inherited from parent classes:
import { entityClassHasTrait } from "@yagejs/core";
if (!entityClassHasTrait(Chest, Interactable)) { throw new Error("Chest must implement Interactable");}This pattern is useful for interaction systems — a single “press E to interact”
prompt can work with chests, NPCs, doors, and any future entity that gains the
Interactable trait.
Blueprints (Deprecated Alternative)
Section titled “Blueprints (Deprecated Alternative)”Blueprints (defineBlueprint()) are the older way to define entity factories.
defineBlueprint and Blueprint are deprecated. They still work and you may
encounter them in existing code, but entity subclasses offer better type
safety and a class-based API.
// Deprecated: write an Entity subclass insteadimport { defineBlueprint, Transform, Vec2 } from "@yagejs/core";import { SpriteComponent, texture } from "@yagejs/renderer";
const CoinTex = texture("assets/coin.png");
const CoinBlueprint = defineBlueprint("Coin", (entity, params: { position: Vec2 }) => { entity.add(new Transform({ position: params.position })); entity.add(new SpriteComponent({ texture: CoinTex }));});
const coin = scene.spawn(CoinBlueprint, { position: new Vec2(64, 32) });Write new entity types as subclasses. Blueprints you already have keep spawning, and both kinds can live in one scene.
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/patterns.md.