Common Game Patterns
This page covers practical patterns that come up in most games built with YAGE.
Entity Spawning
Section titled “Entity Spawning”An entity type is an Entity subclass whose setup() adds its components.
scene.spawn() passes its second argument to setup(), typed from the
signature. The Entity Subclasses guide covers
the pattern in depth.
import { Component, Entity, Transform, Vec2 } from "@yagejs/core";import { SpriteComponent, texture } from "@yagejs/renderer";import { RigidBodyComponent, ColliderComponent } from "@yagejs/physics";import { InputManagerKey } from "@yagejs/input";
const bulletTex = texture("assets/bullet.png");
interface BulletParams { position: Vec2; velocity: Vec2; }
class Bullet extends Entity { setup({ position, velocity }: BulletParams): void { this.add(new Transform({ position })); this.add(new SpriteComponent({ texture: bulletTex })); const body = this.add(new RigidBodyComponent({ type: "dynamic", gravityScale: 0 })); this.add(new ColliderComponent({ shape: { type: "circle", radius: 4 } })); body.setVelocity(velocity); }}
// A component on the player spawns bullets.class Gun extends Component { private readonly input = this.service(InputManagerKey); private readonly transform = this.sibling(Transform);
update(): void { if (this.input.isJustPressed("fire")) { this.scene.spawn(Bullet, { position: this.transform.worldPosition, velocity: new Vec2(800, 0), }); } }}A named spawn, such as scene.spawn("background"), suits a one-off entity with
no behaviour of its own: a background, a UI root or a HUD host.
defineBlueprint still works but is deprecated. Write an entity subclass
instead, which can also carry methods and fields. Per-frame logic goes in a
component: an Entity subclass has no update().
Entity Destruction
Section titled “Entity Destruction”Destroy entities when they’re no longer needed:
entity.destroy();Destruction is deferred to the end of the current frame — it’s safe to call
during update() or event handlers.
Always check isDestroyed when iterating entities that might be destroyed
mid-loop:
for (const entity of scene.getEntities()) { if (entity.isDestroyed) continue; // process entity...}Components are automatically cleaned up when their entity is destroyed.
SoundComponent stops playback, RigidBodyComponent removes the physics body.
Parent-Child Entities
Section titled “Parent-Child Entities”Attach child entities for health bars, indicators, or attached effects:
import { Entity, Transform, Vec2 } from "@yagejs/core";import { GraphicsComponent, SpriteComponent, texture } from "@yagejs/renderer";
const playerTex = texture("assets/player.png");
class Player extends Entity { setup({ x, y }: { x: number; y: number }): void { this.add(new Transform({ position: new Vec2(x, y) })); this.add(new SpriteComponent({ texture: playerTex }));
// Health bar follows the player const healthBar = this.spawnChild("healthBar"); healthBar.add(new Transform({ position: new Vec2(0, -30) })); // offset above healthBar.add(new GraphicsComponent().draw((g) => g.rect(-20, -3, 40, 6).fill(0x44dd44))); }}spawnChild spawns the child and attaches it in one call. To attach an entity
that already exists, call parent.addChild(name, child).
Child transforms are relative to the parent. When the parent moves, children follow. Destroying the parent destroys all children.
Child names are unique per parent, and a destroyed child the scene owns holds its
name until the end-of-frame flush. Swapping one child for another under the same
name in a single tick therefore starts with removeChild, which detaches the
child and frees the name straight away:
class Shotgun extends Entity {}declare const player: Player; // a spawned Player with a "weapon" child
const old = player.removeChild("weapon"); // the name is free from here onold.destroy();player.spawnChild("weapon", Shotgun);Cooldowns
Section titled “Cooldowns”Use ProcessSlot for cooldowns and timed abilities:
import { Component, ProcessComponent, ProcessSlot } from "@yagejs/core";import { InputManagerKey } from "@yagejs/input";
class ShootAbility extends Component { private readonly input = this.service(InputManagerKey); private pc = this.sibling(ProcessComponent); private cooldown!: ProcessSlot;
onAdd(): void { // slot() lives on ProcessComponent, so make sure the entity has one if (!this.entity.tryGet(ProcessComponent)) this.entity.add(new ProcessComponent()); this.cooldown = this.pc.slot({ duration: 0.3 }); // 0.3s cooldown }
update(): void { if (this.input.isJustPressed("fire") && this.cooldown.completed) { this.fireBullet(); this.cooldown.start(); // begin the cooldown; completed is false until it elapses } }
private fireBullet(): void { // spawn the bullet... }}The slot exposes completed and running, so you can gate actions on it.
For repeating effects, give the slot an onComplete, then restart() it:
import { Component, ProcessComponent } from "@yagejs/core";
class WaveSpawner extends Component { private pc = this.sibling(ProcessComponent);
onAdd(): void { const wave = this.pc.slot({ duration: 0.5, onComplete: () => this.spawnWave() }); wave.start(); // later, to run it again: wave.restart(); }
private spawnWave(): void { // spawn the next wave... }}Named states
Section titled “Named states”StateMachine keeps a component’s valid modes and transitions in one table.
Use it when a string field would otherwise be assigned from several places.
import { Component, defineStates } from "@yagejs/core";
class GuardBrain extends Component { readonly mode = this.stateMachine( defineStates({ patrol: { to: ["suspicious"] }, suspicious: { to: ["patrol", "alert"], for: 0.5, next: "alert" }, alert: { to: ["patrol"] }, }), "patrol", );
fixedUpdate(dt: number): void { if (this.mode.is("patrol") && this.seesPlayer()) { this.mode.go("suspicious"); } if (this.mode.is("alert") && !this.seesPlayer()) { this.mode.go("patrol"); } this.mode.tick(dt); }
private seesPlayer(): boolean { // line-of-sight check... return false; }}The keys passed to defineStates become the state-name type. TypeScript rejects
unknown names in to, next, and go(). go() also checks the current
state’s to list at runtime, and throws before any hook runs.
The first tick() runs the initial state’s enter hook. Call start() from
onAdd when the hook should run before the first frame instead. Construction
and hydrate() run no hooks. Enter and exit hooks from this.stateMachine(...)
are attributed to the owning component when they throw.
A timed state needs a for and a different next that it can reach: its own
to, its parent’s to, or a state marked fromAny. for is a number of
seconds or a function returning one. Pass the component’s dt directly to tick(). The
engine has already applied scene and entity time scaling. Time past a state
boundary is discarded, so a large tick enters at most one state per level.
Calling go() with the current state restarts it when that state lists itself
in to: the exit hook runs, the timer resets, and the enter hook runs
again. A state that does not list itself ignores the call.
A few states tend to be reachable from everywhere, such as hit and die.
Mark those with fromAny: true instead of repeating them in every to list:
import { defineStates } from "@yagejs/core";
defineStates({ patrol: { to: ["windup"] }, windup: { to: ["strike"], for: 0.2, next: "strike" }, strike: { to: ["patrol"] }, hit: { fromAny: true, to: ["patrol"] }, die: { fromAny: true },});fromAny adds no edge from the state to itself, so a second hit does not
restart hit unless hit lists itself in to.
for also takes a function, called each time the state is entered. Use it for a
duration that comes from tuning or changes per entry. Because it runs at entry
rather than while the table is built, it can read a field the constructor
assigns after the machine field is initialized:
import { Component, defineStates } from "@yagejs/core";
interface EnemyTuning { windup: number;}
class EnemyBrain extends Component { private readonly tuning: EnemyTuning;
readonly mode = this.stateMachine( defineStates({ patrol: { to: ["windup"] }, windup: { to: ["strike"], for: () => this.tuning.windup, next: "strike" }, strike: { to: ["patrol"] }, }), "patrol", );
constructor(tuning: EnemyTuning) { super(); this.tuning = tuning; }}A returned value that is not finite and above 0 throws before the transition commits, leaving the machine where it was.
When a callback can arrive after the machine has moved on, ask first:
import { Component, defineStates } from "@yagejs/core";import { AnimationController } from "@yagejs/renderer";
class Guard extends Component { private readonly anim = this.sibling(AnimationController); readonly mode = this.stateMachine( defineStates({ patrol: {}, hit: { fromAny: true, to: ["patrol"] }, die: { fromAny: true }, }), "patrol", );
takeHit(): void { this.mode.go("hit"); this.anim.playOneShot("hit", { onComplete: () => { if (this.mode.canGo("patrol")) this.mode.go("patrol"); }, }); }}canGo() reports whether go() would move the machine, and never throws.
Driving presentation from events
Section titled “Driving presentation from events”Every machine reports three events, typed with its own state names: changed
({ from, to }), entered ({ state, from }), and exited ({ state, to }).
A presentation component can follow the machine without the table knowing
anything about animation:
import { AnimationController } from "@yagejs/renderer";
class GuardView extends Component { private readonly anim = this.sibling(AnimationController); private readonly guard = this.sibling(GuardBrain);
onAdd(): void { const { mode } = this.guard; this.listen(mode, mode.events.entered, ({ state }) => this.anim.play(state)); }}this.listen drops the subscription when the component is removed. Outside a
component, mode.on(mode.events.entered, handler) returns an unsubscribe
function.
To let any component or the scene listen without holding a reference to the machine, give it a name for its events:
import { Component, defineStates } from "@yagejs/core";
const states = defineStates({ patrol: { to: ["alert"] }, alert: { to: ["patrol"] },});
class GuardBrain extends Component { readonly mode = this.stateMachine(states, "patrol", { events: "mode" });}The tokens are then published on the entity as mode:changed, mode:entered
and mode:exited, so entity.on and scene.on see them like any entity event.
Entity events dispatch by name, so two machines on one entity need different
names. Without a name the events stay on the machine.
On a transition the order is: exit hook, exited, the state changes, enter
hook, entered, changed. The first entry emits entered with from: null
and no changed, and hydrate() emits nothing. A handler may not move the
machine that called it.
Phases inside a state
Section titled “Phases inside a state”A state that covers a sequence declares its phases with states, and the phase
entered with it as start:
import { defineStates } from "@yagejs/core";
defineStates({ idle: { to: ["shoot", "hit"] }, shoot: { to: ["idle", "hit"], // reachable from every phase start: "aim", states: { aim: { to: ["fire"], for: 0.2, next: "fire" }, fire: { to: ["recoil"], for: 0.1, next: "recoil" }, recoil: { to: ["idle"], for: 0.3, next: "idle" }, }, }, hit: { to: ["idle"] },});go("shoot") enters aim, and the phases run themselves to recoil, which
ends the sequence by entering idle. A hit landing during any phase calls
go("hit"): the current phase’s exit runs, then shoot’s. The sequence
cannot outlive the state that holds it, so no phase is left current while the
entity is somewhere else.
state reads the current phase and is("shoot") is true throughout the
sequence. entered and exited fire at both levels, parent then phase on the
way in and phase then parent on the way out, while changed fires once and
names the phases. Phase names live in the same union as every other state, so they are
unique across the table. A phase may name its siblings, its parent, and
top-level states; naming another state’s phase is rejected when the machine is
built. Giving the parent its own for and next puts a deadline on the whole
sequence, and that next has to leave the sequence. Nesting is one level deep — independent axes stay separate machines.
The machine implements Serializable, so a chosen save root can include it.
serialize() returns the current state and its elapsed time, plus the parent’s
elapsed time inside a sequence, and hydrate() restores them without running
hooks. A component field without a leading underscore also appears in the
Inspector with its current state, elapsed time, and last transition.
Use separate machines for independent modes such as movement, control, and attachment. Leave values derived from other state as getters. The abilities addon remains the better fit for actions that need input intents, lanes, priorities, holds, or timed step windows.
Health and Damage
Section titled “Health and Damage”A simple health component:
import { Component, defineEvent } from "@yagejs/core";
const EntityDied = defineEvent("entity:died");
class HealthComponent extends Component { hp: number; readonly maxHp: number;
constructor(maxHp: number) { super(); this.hp = maxHp; this.maxHp = maxHp; }
takeDamage(amount: number): void { this.hp = Math.max(0, this.hp - amount); if (this.hp <= 0) { this.entity.emit(EntityDied); } }
heal(amount: number): void { this.hp = Math.min(this.maxHp, this.hp + amount); }
get ratio(): number { return this.hp / this.maxHp; }}Handle damage via collision events:
import { ColliderComponent } from "@yagejs/physics";
class DamageComponent extends Component { constructor(readonly amount: number) { super(); }}
class Hurtbox extends Component { onAdd(): void { const collider = this.entity.get(ColliderComponent); collider.onCollision((ev) => { if (ev.started) { const health = this.entity.tryGet(HealthComponent); const damage = ev.other.tryGet(DamageComponent); if (health && damage) { health.takeDamage(damage.amount); } } }); }}Scoring
Section titled “Scoring”Keep the score in a component on a host entity, and let events change it:
import { Component, Entity, Scene, defineEvent } from "@yagejs/core";
const CoinCollected = defineEvent("coin:collected");const EnemyDefeated = defineEvent<{ points: number }>("enemy:defeated");
class Score extends Component { private _points = 0;
get points(): number { return this._points; }
onAdd(): void { // Coins and enemies emit on themselves; the events bubble to the scene. this.listenScene(CoinCollected, () => { this._points += 10; }); this.listenScene(EnemyDefeated, ({ points }) => { this._points += points; }); }}
class ScoreKeeper extends Entity { setup(): void { this.add(new Score()); }}
class GameScene extends Scene { readonly name = "game";
onEnter(): void { this.spawn(ScoreKeeper, { key: "score" }); }}
// Anywhere else in the scene, such as another component:class ScoreLabel extends Component { update(): void { const points = this.scene.findByKey("score")?.get(Score).points ?? 0; // ...show points }}Coins and enemies emit events without knowing about the score. The Score
component hears them and keeps the total. The host is destroyed with the
scene, so the score starts at 0 each time the scene is entered.
State Management
has the full recipe, including a HUD and a React read.
Ground Detection with Raycasting
Section titled “Ground Detection with Raycasting”For platformers, use a short downward raycast to detect ground:
import { Component, ProcessComponent, ProcessSlot, Transform, Vec2,} from "@yagejs/core";import { PhysicsWorldKey, type PhysicsWorld } from "@yagejs/physics";
const PLAYER_HALF_HEIGHT = 16;const COYOTE_TIME = 0.1; // seconds
class PlayerController extends Component { private readonly transform = this.sibling(Transform); private readonly processes = this.sibling(ProcessComponent); private world!: PhysicsWorld; /** Running while a jump is still allowed. */ private coyote!: ProcessSlot;
onAdd(): void { this.world = this.use(PhysicsWorldKey); // On the physics clock, like the fixedUpdate that restarts it. this.coyote = this.processes.slot({ duration: COYOTE_TIME, clock: "fixed" }); }
fixedUpdate(): void { // Raycast downward from entity center const hit = this.world.raycast( this.transform.worldPosition, Vec2.DOWN, PLAYER_HALF_HEIGHT + 2, // just past the feet { excludeEntity: this.entity }, // skip the player's own collider ); // Standing on ground keeps the window open. It closes by itself // COYOTE_TIME seconds after the player walks off a ledge. if (hit) this.coyote.restart(); }
get canJump(): boolean { return this.coyote.running; }}Coyote time gives the player a brief window to jump after walking off a
ledge, making platforming feel more forgiving. The window is a ProcessSlot
rather than a number counted down by hand, so it pauses with the scene and
follows its time scale. The entity needs a ProcessComponent. The
platformer example
pairs it with a jump buffer.
Entity Finding
Section titled “Entity Finding”Find specific entities by name, or by the key they were spawned with:
const player = scene.findEntity("player");const score = scene.findByKey("score"); // spawned with { key: "score" }Query entities by component:
class EnemyTag extends Component {}
const enemies = scene.findEntities({ filter: (e) => e.has(EnemyTag) && e.has(HealthComponent),});For a set a component reads every frame, register a live query with the
QueryCache. It updates as components are added and removed. Unregister it
when the component goes away, or it keeps receiving updates. The cache is
shared by the engine, so the query also matches entities in the other scenes
on the stack:
import { QueryCacheKey, type QueryResult } from "@yagejs/core";
class EnemyRadar extends Component { private enemies!: QueryResult;
onAdd(): void { const cache = this.use(QueryCacheKey); this.enemies = cache.register([HealthComponent, EnemyTag]); this.addCleanup(() => cache.unregister(this.enemies)); }
update(): void { for (const entity of this.enemies) { // process each enemy with health... } }}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.