Skip to content

Common Game Patterns

This page covers practical patterns that come up in most games built with YAGE.

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().

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.

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 on
old.destroy();
player.spawnChild("weapon", Shotgun);

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...
}
}

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.

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.

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.

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);
}
}
});
}
}

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.

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.

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.