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,createCounterkeyed 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.
Why opt-in
Section titled “Why opt-in”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.
Spawning a keyed entity
Section titled “Spawning a keyed entity”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).
The Openable pattern
Section titled “The Openable pattern”A chest that remembers it has been opened. Three pieces:
1. A reactive set, keyed by entity id
Section titled “1. A reactive set, keyed by entity id”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.
2. A trait — an interface contract
Section titled “2. A trait — an interface contract”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”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.
Putting it together
Section titled “Putting it together”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.Variants
Section titled “Variants”The same composition handles more than chests.
One-shot cutscene flag
Section titled “One-shot cutscene flag”import { Component, Entity, createSet, defineEvent } from "@yagejs/core";
export const triggered = createSet<string>();
// game/components/OneShot.tsexport 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" });Respawnable resource node
Section titled “Respawnable resource node”import { Component, createMap } from "@yagejs/core";
// "harvested at frame N" → respawns after some intervalexport 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); }}Notes & gotchas
Section titled “Notes & gotchas”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.
With tilemaps
Section titled “With tilemaps”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.