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.
Game state on a host entity
Section titled “Game state on a host entity”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:
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";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:
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.
RunProgressis the only code that changes it. - Three ways to reach it. Use
scene.findByKey(HUD_KEY)with the spawn key, a query such asscene.findEntities({ filter: (e) => e.has(RunProgress) }), or the reference thatspawn()returned. - Visible to tests. The Inspector reports a component’s getters, so an
e2e test can read
coinsandlivesfrom 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.
Changing state with events
Section titled “Changing state with events”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.
Reading state from React UI
Section titled “Reading state from React UI”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.
Saved state in stores
Section titled “Saved state in stores”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.
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.
Combining Patterns
Section titled “Combining Patterns”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/savepersists. React reads it withuseStore.
Anti-Patterns
Section titled “Anti-Patterns”- Global mutable state. Avoid
window.scoreor a module-levellet 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
ServiceKeywithcontext.registerorscene.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,findByKeyand the Inspector don’t see it. - Game state on the scene. Avoid a field on the
Scenesubclass that closures inonEnterchange. The rules end up in the scene instead of a component, and other code has to castthis.sceneto 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.