Skip to content

State Management

Game state such as a score, lives, an inventory or a level clock lives in a component on a host entity. Other entities change it by emitting events, and any code in the scene reads it through the host. State that @yagejs/save persists goes in a reactive store instead.

Spawn one entity to host the state, and give it a key. A component on that entity keeps the state in fields, listens for the events that change it, and exposes it through getters. This HUD counts the coins and lives of one run:

constants.ts
import { defineEvent } from "@yagejs/core";
export const CoinCollected = defineEvent("coin:collected");
export const PlayerDied = defineEvent("player:died");
export const GoalReached = defineEvent("goal:reached");
export const TOTAL_COINS = 9;
/** Spawn key of the HUD entity, for `scene.findByKey`. */
export const HUD_KEY = "hud";
hud.ts
import { Component, Entity } from "@yagejs/core";
import { Anchor, UISurface, type UIText } from "@yagejs/ui";
import { CoinCollected, PlayerDied } from "./constants";
/** Coins and lives for one run. */
export class RunProgress extends Component {
private readonly label: UIText;
private _coins = 0;
private _lives = 3;
constructor(label: UIText) {
super();
this.label = label;
}
get coins(): number {
return this._coins;
}
get lives(): number {
return this._lives;
}
onAdd(): void {
this.refresh();
// Coins and hazards emit on themselves. The events bubble to the scene,
// where this component hears them.
this.listenScene(CoinCollected, () => {
this._coins += 1;
this.refresh();
});
this.listenScene(PlayerDied, () => {
this._lives = Math.max(0, this._lives - 1);
this.refresh();
});
}
private refresh(): void {
this.label.setText(`Coins ${this._coins} Lives ${this._lives}`);
}
}
/** The HUD, and the host of the run's state. */
export class HudEntity extends Entity {
progress!: RunProgress;
setup(): void {
const panel = this.add(
new UISurface({ anchor: Anchor.TopRight, offset: { x: -16, y: 16 } }),
);
const label = panel.text("", { fontSize: 20, fill: 0xffe66d });
this.progress = this.add(new RunProgress(label));
}
}

The scene’s onEnter only spawns the host, with the key that other code looks it up by:

scene.ts
import { Entity, Scene } from "@yagejs/core";
import { HUD_KEY } from "./constants";
import { HudEntity } from "./hud";
class PlayerEntity extends Entity {}
class LevelScene extends Scene {
readonly name = "level";
onEnter(): void {
this.spawn(HudEntity, { key: HUD_KEY });
this.spawn(PlayerEntity);
// ...the rest of the level
}
}

Any component in the scene reads the state through the key:

import { Component } from "@yagejs/core";
import { HUD_KEY, TOTAL_COINS } from "./constants";
import type { HudEntity } from "./hud";
class GoalGate extends Component {
get open(): boolean {
const progress = this.scene.findByKey<HudEntity>(HUD_KEY)?.progress;
return progress !== undefined && progress.coins === TOTAL_COINS;
}
}

What this gives you:

  • A fresh run on every enter. The host is destroyed with the scene, so entering the scene again starts from zero coins. There is no reset code.
  • One owner for the rules. Coins and hazards emit an event and know nothing about the score. RunProgress is the only code that changes it.
  • Three ways to reach it. Use scene.findByKey(HUD_KEY) with the spawn key, a query such as scene.findEntities({ filter: (e) => e.has(RunProgress) }), or the reference that spawn() returned.
  • Visible to tests. The Inspector reports a component’s getters, so an e2e test can read coins and lives from the running game.

The platformer example is built this way: a RunProgress component on its HUD entity keeps the coin count and the win state. Its source is in examples/src/platformer/hud.ts.

Declare an event with defineEvent:

import { defineEvent } from "@yagejs/core";
const CoinCollected = defineEvent("coin:collected");
const PlayerDied = defineEvent("player:died");

Emit from the entity where it happens. An entity event bubbles up to the scene:

import { Component } from "@yagejs/core";
import { ColliderComponent } from "@yagejs/physics";
import { CoinCollected } from "./constants";
class CoinPickup extends Component {
onAdd(): void {
// In a coin's trigger handler
this.sibling(ColliderComponent).onTrigger((ev) => {
if (ev.entered) {
this.entity.emit(CoinCollected);
this.entity.destroy();
}
});
}
}

Listen from the component that owns the state, with this.listenScene, as RunProgress does above. The subscription ends when the component is removed.

The scene’s onEnter may also connect an event to a component method in one line:

import { Component, Entity, Scene, defineEvent } from "@yagejs/core";
const PlayerHit = defineEvent("player:hit");
class Health extends Component {
hp = 3;
damage(amount: number): void {
this.hp = Math.max(0, this.hp - amount);
}
}
class Player extends Entity {
setup(): void {
this.add(new Health());
}
}
class LevelScene extends Scene {
readonly name = "level";
onEnter(): void {
const player = this.spawn(Player);
this.on(PlayerHit, () => player.get(Health).damage(1));
}
}

Keep it to that one line. The state and the rule stay in the component.

This keeps entities decoupled: a coin doesn’t need to know about the score. It emits an event, and the component that keeps the score does the rest.

A React tree reads entity-hosted state with useSceneSelector. The selector runs every frame, and the component re-renders only when the result changes:

import { Text, useSceneSelector } from "@yagejs/ui-react";
import { HUD_KEY } from "./constants";
import type { HudEntity } from "./hud";
function CoinCounter() {
const coins = useSceneSelector(
(scene) => scene.findByKey<HudEntity>(HUD_KEY)?.progress.coins ?? 0,
);
return <Text>{`Coins: ${coins}`}</Text>;
}

The React tree reads the state. Changes still go through events to the component that owns it.

State that @yagejs/save keeps between sessions, such as settings, unlocked levels or a best score, lives in a reactive store. Create it at module scope with createStore, or with a leaf factory such as createRecord. This is the only game state that belongs at module scope, because the save layer restores it before the scene that reads it is entered.

persistence/stores.ts
import { createStore } from "@yagejs/core";
export const records = createStore((s) => ({
bestCoins: s.counter({ default: 0 }),
unlocked: s.set<string>(),
}));

A component writes to the store when its rule says so:

import { Component } from "@yagejs/core";
import { GoalReached } from "./constants";
import { records } from "./persistence/stores";
class RunProgress extends Component {
private _coins = 0;
onAdd(): void {
// ...the CoinCollected and PlayerDied listeners from hud.ts
this.listenScene(GoalReached, () => {
if (this._coins > records.bestCoins.value()) {
records.bestCoins.set(this._coins);
}
});
}
}

React reads a store with useStore, which re-renders on every change:

import { Text, useStore } from "@yagejs/ui-react";
import { records } from "./persistence/stores";
function BestCoins() {
const best = useStore(records.bestCoins);
return <Text>{`Best: ${best}`}</Text>;
}

Register the store with the save system by id, for example save.autoPersist("records", records). The Save & Load guide covers slots, restore order and migrations. For a domain model that the store factories don’t fit, implement Serializable<TEncoded> yourself, as Custom state roots shows.

A typical game uses all three:

  • A component on a host entity holds the run’s state and the rules that change it.
  • Events tell that component that something happened.
  • A store holds what @yagejs/save persists. React reads it with useStore.
  • Global mutable state. Avoid window.score or a module-level let score. Nothing resets it when the scene restarts, and it breaks scene isolation and testing. A module-level store for saved state is the one exception.
  • Game state in a service. Don’t register game state under a ServiceKey with context.register or scene.registerScoped. Service keys are for plugin infrastructure, such as the renderer, the physics world and the input manager. A service is not an entity, so queries, findByKey and the Inspector don’t see it.
  • Game state on the scene. Avoid a field on the Scene subclass that closures in onEnter change. The rules end up in the scene instead of a component, and other code has to cast this.scene to read the field.
  • Changing another entity’s components directly. Don’t reach into another entity’s components to change their state. Emit an event, and let the component that owns the state change it.
  • Storing state in the UI layer. Keep game state in components. The UI reads state; it doesn’t own it.

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.