Skip to content

Persistent World State

YAGE ships two primitives that compose into the canonical “openable chest” pattern (and most other world-fact patterns games need):

  • Stable identity — opt-in per-scene keys for entities whose state should persist across save/load or scene navigation. scene.spawn(..., { key }), entity.key, scene.findByKey(key).
  • Reactive stores — createSet, createMap, createCounter keyed by string IDs.

Identity is just an enabler. The save layer doesn’t see entity keys; it only sees stores. Game code threads the stable id through both.

Most entities never need a stable key — bullets, particles, transient enemies, UI markers. A key is metadata for the entities you want to address by name across time: chests, doors, named NPCs, spawn anchors, one-shot trigger volumes. Keeping identity opt-in avoids tempting callers to use it as a general-purpose lookup and keeps the base Entity lean.

import { Entity } from "@yagejs/core";
import { Chest } from "./chest.js";
class SpawnAnchor extends Entity {}
const parent = scene.spawn("manor");
// Class with setup params + key:
scene.spawn(Chest, { content: ["potion"] }, { key: "forest/chest-01" });
// Class with no setup params, just a key:
scene.spawn(SpawnAnchor, { key: "anchors/forest" });
// Anonymous keyed entity:
scene.spawn("village-bell", { key: "village/bell" });
// Keyed child (4-arg form: name, Class, setup params, options):
parent.spawnChild("treasure", Chest, { content: ["potion"] }, { key: "manor/chest-attic" });

The 4-argument spawnChild form mirrors scene.spawn(Class, params, options): the third argument is whatever the entity’s setup(params) accepts (here, Chest.setup({ content }) defined further down on this page), and the fourth is the spawn options object.

Look it up later, anywhere in the scene:

import type { Chest } from "./chest.js";
const chest = scene.findByKey<Chest>("forest/chest-01");

findByKey is scene-scoped and hides destroyed entities (between entity.destroy() and end-of-frame flush, the index still holds the entry, but findByKey returns undefined so callers don’t act on a dying entity).

A chest that remembers it has been opened. Three pieces:

game/world/persistence.ts
import { createSet } from "@yagejs/core";
export const opened = createSet<string>();

The store is keyed by string — those strings are entity keys. The save layer serialises it under whatever id you choose at the call site (save.persist("world.opened", opened)); identity is what makes the strings meaningful.

game/traits/Openable.ts
import { defineTrait } from "@yagejs/core";
export interface Openable {
isOpen(): boolean;
open(): void;
}
export const Openable = defineTrait<Openable>("Openable");

Traits are queryable interface contracts, not custom Components. Different entity classes can declare the same trait with @trait(Openable) and implement it with different components — chests, treasure piles, secret doors — and scene.findEntities({ trait: Openable }) returns all of them.

3. A component that wires identity + store

Section titled “3. A component that wires identity + store”
game/components/OpenableState.ts
import { Component, defineEvent } from "@yagejs/core";
import { opened } from "../world/persistence.js";
import { Openable } from "../traits/Openable.js";
export const Opened = defineEvent<{ silent?: boolean }>("opened");
export class OpenableState extends Component implements Openable {
onAdd(): void {
const id = this.entity.requireKey();
if (opened.has(id)) {
this.entity.emit(Opened, { silent: true });
}
}
isOpen(): boolean {
return opened.has(this.entity.requireKey());
}
open(): void {
const id = this.entity.requireKey();
if (opened.has(id)) return;
opened.add(id);
this.entity.emit(Opened, { silent: false });
}
}

requireKey() throws if the entity wasn’t spawned with { key }. That fail- loud behaviour is intentional: a component that depends on identity should crash early rather than silently mishandle keyless entities.

scene.spawn assigns the key before it runs the entity’s setup(), so requireKey() is safe in the onAdd() of every component that setup() adds.

The entity declares the trait and hands the work to the component:

import { Component, Entity, trait } from "@yagejs/core";
import { SpriteComponent } from "@yagejs/renderer";
import { Openable } from "./traits/Openable.js";
import { OpenableState, Opened } from "./components/OpenableState.js";
class ChestSprite extends Component {
constructor(readonly content: string[]) {
super();
}
onAdd(): void {
this.entity.on(Opened, ({ silent }) => {
// `silent` marks a chest that was already open when it spawned:
// show it open instead of playing the opening frame.
this.sibling(SpriteComponent).setTexture(silent ? "chest-open" : "chest-opening");
});
}
}
@trait(Openable)
export class Chest extends Entity implements Openable {
override setup(params: { content: string[] }): void {
this.add(new SpriteComponent({ texture: "chest-closed" }));
this.add(new ChestSprite(params.content));
// Added last: its onAdd() emits `Opened` for an already-open chest,
// and ChestSprite has to be listening by then.
this.add(new OpenableState());
}
isOpen(): boolean {
return this.get(OpenableState).isOpen();
}
open(): void {
this.get(OpenableState).open();
}
}
// Game code:
scene.spawn(Chest, { content: ["potion"] }, { key: "forest/chest-01" });
// Player opens the chest:
scene.findByKey<Chest>("forest/chest-01")?.get(OpenableState).open();
// Later (e.g. after save/load), the same chest skips the open animation
// because `opened` already contains its key.

The same composition handles more than chests.

game/world/persistence.ts
import { Component, Entity, createSet, defineEvent } from "@yagejs/core";
export const triggered = createSet<string>();
// game/components/OneShot.ts
export const Triggered = defineEvent("triggered");
class OneShotTrigger extends Component {
fire(): void {
const id = this.entity.requireKey();
if (triggered.has(id)) return;
triggered.add(id);
this.entity.emit(Triggered);
}
}
class StoryTrigger extends Entity {
setup(): void {
this.add(new OneShotTrigger());
}
}
scene.spawn(StoryTrigger, { key: "intro/meet-elder" });
import { Component, createMap } from "@yagejs/core";
// "harvested at frame N" → respawns after some interval
export const harvestedAt = createMap<string, number>();
class RespawnableState extends Component {
isHarvested(currentFrame: number, cooldown: number): boolean {
const at = harvestedAt.get(this.entity.requireKey());
return at !== undefined && currentFrame - at < cooldown;
}
harvest(currentFrame: number): void {
harvestedAt.set(this.entity.requireKey(), currentFrame);
}
}

Keys are immutable per entity. No rename API. Destroy + respawn.

Identity is per-scene. Stacked scenes (e.g. paused inventory over a game scene) each have their own index. Cross-scene lookups go through whichever scene owns the entity.

Identity is independent of @yagejs/save. The save layer reads stores, not entity keys. You can use the identity primitive without enabling save, and you can use save without keying every entity. They compose because game code threads the stable id through both.

For Tiled-backed levels, hand-typing keys per chest gets tedious fast. The tilemap guide covers tilemap.objectKey(obj) and tilemap.forEachObject(layer, cb), which derive a deterministic key from the map asset + Tiled object id and let you spawn keyed entities straight from the level file:

import { TilemapComponent } from "@yagejs/tilemap";
import { Chest } from "./chest.js";
const tilemap = entity.get(TilemapComponent);
tilemap.forEachObject("interactables", (obj, key) => {
if (obj.class === "Chest") {
const content = tilemap.getPropertyArray<string>(obj, "content") ?? [];
scene.spawn(Chest, { content }, { key });
}
});

The key shape is <prefix>#object:<id> and stays stable across re-saves of the Tiled file (as long as object ids don’t change), so opened.has(key) keeps working after a reload.

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.