# @yagejs/core

Zero runtime dependencies. ECS foundation, DI, game loop, scenes, events, processes.

## Key Exports

### Architecture

| Export            | Purpose                                                                                |
| ----------------- | -------------------------------------------------------------------------------------- |
| `Engine`          | Entry point; plugin orchestration, game loop, scene manager                            |
| `EngineContext`   | DI container                                                                           |
| `ServiceKey<T>`   | Typed DI key                                                                           |
| `Scene`           | Abstract scene base class                                                              |
| `SceneManager`    | Stack-based scene management (push/pop/replace)                                        |
| `Entity`          | Named component container                                                              |
| `EntityPool`      | Reuses entities instead of spawning and destroying them; grows on demand unless capped |
| `EntityHandle<T>` | Reference to one life of an entity; reads `undefined` once that life ends              |
| `Component`       | Base class for game logic                                                              |
| `StateMachine`    | Typed transition table with owner-driven timing, hooks, inspection, and save data      |
| `System`          | Base class for engine-level systems                                                    |
| `Phase`           | Enum: EarlyUpdate, FixedUpdate, Update, LateUpdate, Render, EndOfFrame                 |

`ServiceKey` uses its id string for identity. Two keys with the same id
resolve the same service. A package may repeat an id for the same service
contract when a value import would add an optional runtime dependency. Keep a
comment beside the repeated declaration that names the package that owns the
key.

`new ServiceKey<T>(id, { scope?, missingHint? })`. `missingHint` is appended to
the error `resolve` / `use` throws when the key resolves nowhere; name what
provides the service (`"Install HapticsPlugin."`). `InspectorKey`'s hint names
`DebugPlugin` and `InspectorPlugin`.

### Entity

```ts
import { Entity as BaseEntity } from "@yagejs/core";
import type {
  Blueprint,
  ClassSpawnArgs,
  EntityHandle,
  Scene,
  SpawnOptions,
} from "@yagejs/core";

declare class Entity extends BaseEntity {
  readonly name: string;
  readonly key?: string; // stable identity (opt-in)
  get scene(): Scene; // throws if detached
  get tryScene(): Scene | null; // null if detached
  get activeSelf(): boolean; // own bit
  get isActive(): boolean; // own bit AND every ancestor's
  get generation(): number; // which life; moves on when one ends
  setActive(active: boolean): void;
  handle(): EntityHandle<this>; // reference that expires with this life
  requireKey(): string; // throws if no key
  addChild(name: string, child: Entity): void;
  spawnChild(name: string, options?: SpawnOptions): Entity;
  // Trailing args derived from the entity's setup() signature.
  spawnChild<E extends Entity>(
    name: string,
    Class: new () => E,
    ...rest: ClassSpawnArgs<E>
  ): E;
  /** @deprecated Blueprint overload; use an Entity subclass. */
  spawnChild<P>(
    name: string,
    blueprint: Blueprint<P>,
    params: P,
    options?: SpawnOptions,
  ): Entity;
}
```

- An entity type is an `Entity` subclass whose `setup(params)` adds its components, spawned with `scene.spawn(Class, params)` or `entity.spawnChild(name, Class, params)`. A named spawn (`scene.spawn("background")`, then `.add(...)`) is only for a one-off entity: spawned once, with no behaviour of its own (background, UI root, HUD host).
- `defineBlueprint` / `Blueprint` and the `spawn` / `spawnChild` overloads that take one are deprecated. Existing blueprints still spawn; new entity types are subclasses.
- `entity.scene` throws with a clear error when the entity is detached (not yet spawned, or already destroyed — both the end-of-frame flush and scene teardown clear it). Prefer it in user code — throwing beats letting a `null` propagate silently. Use `entity.tryScene` only in defensive paths (e.g. systems iterating query results during teardown) where detachment is expected.
- `entity.isDestroyed` is true after `destroy()` and for entities torn down with their scene on exit. Teardown also emits `entity:destroyed` once per entity, so listeners tracking entity lifetimes are notified of every destruction, including destruction caused by scene exit.
- `destroy()` deactivates immediately: `isActive` reads `false`, the entity leaves every query, and component `onDisable` fires in the same call. The rest of teardown — `onDestroy`, detaching from the scene — waits for the end-of-frame flush, so `isDestroyed` and component removal still happen later.
- `entity.spawnChild(name, Class, params?)` combines `scene.spawn(...)` + `this.addChild(name, ...)`. Child is auto-added to the parent's scene. Use for sub-entities owned by a parent (enemy body + health bar, player + weapon, etc.).
- `spawnChild` links the parent after the child is built. `this.parent` is `null` for the whole of the child's `setup()` (`entity.parent` in a blueprint's `build()`), including the `onAdd()` of every component that setup adds. It holds the parent once `spawnChild` returns. Pass the parent as a setup param when the child needs it — `this.spawnChild("barrel", Barrel, { owner: this })` — which also gives the child the concrete parent type rather than `Entity | null`. When `setup()` itself must read `this.parent`, reserve both entities in a `scene.spawnBatch` and call `batch.addChild` before `batch.setup`.
- `entity.addChild(name, child)` adopts an existing entity. A scene-less child joins the parent's scene; a child that belongs to a different scene is rejected with an error, because its events bubble to its own scene and that scene's teardown destroys it.
- An entity has no per-frame hook. `update` and `fixedUpdate` are typed as `never` on `Entity`, so a subclass that declares either one fails to compile. The per-frame pass ticks components, so per-frame logic goes in a component on the entity, or in a process on a queue from `makeSceneScopedQueue()` when it outlives a single entity. `Scene` carries the same two `never` declarations.

### Component lookup

```ts yage-context="entity"
import { Component } from "@yagejs/core";

class Cls extends Component {}
const component = new Cls();

entity.add(component); // throws on a second instance of the same exact class
entity.get(Cls); // throws when nothing matches
entity.tryGet(Cls); // undefined when nothing matches
entity.has(Cls); // boolean
entity.remove(Cls); // exact class only
entity.getAll(); // every component, add order
entity.getAll(Cls); // readonly Cls[] — every component assignable to Cls
```

- A class argument matches the class itself **and any subclass of it**. An entity carrying a `SpriteComponent` answers `has(VisualComponent)` with `true`, and `getAll(VisualComponent)` lists it.
- Uniqueness is per exact class: `add` rejects a second `SpriteComponent`, but a base and a subclass on one entity are both legal and both appear in `getAll(Base)`.
- `get` / `tryGet` prefer an exact match. With no exact match they return the single assignable component, and **throw** when more than one is assignable — that is what `getAll(Base)` is for.
- `getAll(Cls)` returns a read-only view in add order. Removing a component replaces the list rather than mutating it, so a walk already in flight visits every member it started with.
- A base class works as an argument even when it is `abstract`.

### Activeness

`setActive(false)` turns an entity off without destroying it — the cheap way to recycle a bullet, a hit spark, or an enemy instead of respawning one.

```ts yage-context="scene"
import { Transform } from "@yagejs/core";

const bullet = scene.spawn("bullet");
bullet.add(new Transform());

bullet.setActive(false); // hidden, physics body off, updates skipped
// ...later
bullet.get(Transform).setPosition(320, 180);
bullet.setActive(true); // back in play, nothing reallocated
```

- Move a `RigidBodyComponent` entity with `rb.setPosition(x, y)`, not a direct `Transform` write: while the entity is active, physics owns the transform of a dynamic body and overwrites the write on the next frame. A `Transform` write made while the entity is inactive is kept and teleports the body there on reactivation, which is what the snippet above does.

- `activeSelf` is the entity's own bit; `isActive` is that bit AND every ancestor's. Deactivating a parent puts the whole subtree to sleep, and each descendant keeps its own `activeSelf` for when the parent wakes.
- A dormant entity drops out of every `QueryCache` query, and out of `scene.findEntity`, `scene.findEntitiesByTag`, `scene.findEntities`, and `filterEntities`. `scene.getEntities()` still returns it for lifecycle tooling and teardown. `scene.findByKey` also still returns it — key lookup is identity, not a search.
- Components keep their own `enabled` flags. A component you disabled by hand is still disabled after the entity comes back.
- Adding a component to a dormant entity runs `onAdd()` but not `onEnable()`, and the entity joins no queries until it is activated.
- `scene.spawn(Class, params?, { active: false })` starts an entity dormant, so `setup()` and every `onAdd()` run without a single `onEnable()` and the entity never joins a query on the way in. `setActive(true)` wakes it. Building a spawner, a room's contents, or a level ahead of time costs nothing until it is switched on.
- A dormant entity's components and its `ProcessComponent` stop being ticked, so tweens and coroutines pause where they are and resume on reactivation.
- Reuse resets nothing: `entity.timeScale`, animation position, process progress, entity event listeners and addon state all survive. Register listeners in `setup()`, and reset game state yourself when you bring an entity back.
- `destroy()` also runs through this same activeness state (`isActive` reads `false` right away, `activeSelf` is untouched), so any code that checks `isActive` to decide whether an entity is "in play" sees a destroyed entity the same way it sees a dormant one.

### Component subscriptions

Subscriptions made through these helpers are released when the component is removed or its entity is destroyed, before `onDestroy`:

```ts
import { Component, createCounter, defineEvent } from "@yagejs/core";

const DamagedEvent = defineEvent<{ amount: number }>("combat:damaged");
const WaveStartEvent = defineEvent<{ wave: number }>("waves:started");
const score = createCounter();

class Turret extends Component {
  onAdd() {
    this.listen(this.entity, DamagedEvent, ({ amount }) => {}); // any entity's token events
    this.listenScene(WaveStartEvent, (data, entity) => {}); // scene.emit + every entity's bubbled emit
    this.listenBus("entity:destroyed", ({ entity }) => {}); // the engine EventBus
    this.addCleanup(score.subscribe(() => {})); // anything else
  }
}
```

- `listen(entity, token, handler)` takes any entity, not only the component's own.
- `listenScene` and `listenBus` throw when the entity is not in a scene; call them from `onAdd()` or later.
- `addCleanup(fn)` registers any other release. Cleanups run in registration order.

### Component enable/disable hooks

`onEnable()` / `onDisable()` fire when a component's _effective_ enabled-ness — `component.enabled && entity.isActive` — changes. `component.effectiveEnabled` reads that state.

```ts
import { Component } from "@yagejs/core";
import { AudioManagerKey, type SoundHandle } from "@yagejs/audio";

class Turret extends Component {
  private beam?: SoundHandle;
  onEnable() {
    this.beam = this.use(AudioManagerKey).play("hum", { loop: true });
  }
  onDisable() {
    this.beam?.stop();
  }
}
```

- Order on add: `onAdd()`, then query join, then `onEnable()`. Order on remove or destroy: `onDisable()`, then cleanups, then `onDestroy()`.
- `onAdd()` runs inside `entity.add()`, before that call returns. A field the caller assigns from the return value is still `undefined` while `onAdd()` runs — in an `Entity` subclass's `setup()`, `this.gun = this.add(new Gun())` leaves `this.gun` unset for the whole of `Gun.onAdd()`. Read a sibling through `this.entity.get(Cls)` or `this.sibling(Cls)` there rather than through a field on the entity.
- `component.destroy()` ends its own life — the same as `entity.remove(SomeClass)`, without having to name its own class from inside itself, which breaks under subclassing.
- Validate dependencies by throwing from `onAdd()`. The throw is attributed to the component, recorded in `Inspector.getErrors().callbackErrors`, and rethrown to the caller of `entity.add()`. Called from `setup()`, that caller is `scene.spawn`, which lets the throw through unchanged and leaves the half-built entity in the scene.
- `onEnable()` sees whatever state the component held while dormant. Put live resources there (sounds, bodies, display objects), not game-state resets.
- Writing `component.enabled` fires the hooks too, so a component disabled by hand releases its resources the same way.
- A throwing hook is attributed to its component and rethrown, like a throwing `update()`. A throw from `onDisable()` during scene teardown stops teardown at that entity.

Engine implementations: a dormant rigid body and collider leave the simulation but keep their allocation, which is what makes reuse cheap; the body's velocity, forces, and torques are cleared on disable, so it comes back at rest. The renderer's visual components, `UISurface`, `ParticleEmitterComponent`, and `TilemapComponent` hide their display object. `SoundComponent` stops playback and does not resume on its own.

Gotcha: a collider disabled and re-enabled while it still overlaps something gets no new collision-start. A reused entity dropped onto an existing contact receives no `onCollision` for it.

### Component update order

Within one entity, `update()` / `fixedUpdate()` run in ascending `updatePriority`; ties run in add order. Undeclared = 0, so add order is the order until a component declares a value. A negative value runs before undeclared siblings, a positive one after them. Sibling order only: entities still update in scene add order.

```ts
import { Component, Entity } from "@yagejs/core";

class Mover extends Component {}
class Brain extends Component {}

class BoundsClamp extends Component {
  static updatePriority = 10; // class default: after the follow that moved the camera
}
class Guard extends Entity {
  setup() {
    this.add(new Mover());
    this.add(new Brain()).updatePriority = -1; // per instance: decides before Mover moves
  }
}
```

- `component.updatePriority` is writable at any time, before or after `add()`; the instance value overrides the class's `static updatePriority`, which subclasses inherit.
- `entity.getAll()` and `entity.getAll(Cls)` stay in add order.
- Adding a component from inside another component's `update()` on the same entity: whether the new one runs in the same pass depends on priorities. While no component on that entity declares an `updatePriority`, the pass walks the live component list and the new component runs in the same frame. Once one declares a priority, the pass walks a sorted list taken before it started, and a component added during the pass first runs next frame. Neither order is worth depending on — do the work the new component would do this frame in the component that adds it.

### Declaring fields on a subclass

`Component` and `Entity` own member names of their own, and a subclass field that reuses one shadows the base member. Taken on `Component`: `entity`, `enabled`, `effectiveEnabled`, `updatePriority`, `scene`, `context`, `use`, `service`, `sibling`, `listen`, `listenScene`, `listenBus`, `addCleanup`, `stateMachine`, `destroy`, plus the hook names `onAdd`, `onEnable`, `onDisable`, `onDestroy`, `update` and `fixedUpdate`. Taken on `Entity`: `id`, `name`, `tags`, `key`, `timeScale`, `scene`, `tryScene`, `parent`, `children`, `activeSelf`, `isActive`, `isDestroyed`, `isPooled`, `generation`, `handle`, `setActive`, `requireKey`, `hasTrait`, `destroy`, `on`, `emit`, the component and child methods `add`, `get`, `tryGet`, `has`, `remove`, `getAll`, `addChild`, `spawnChild`, `removeChild`, `getChild` and `tryGetChild`, and the hook names `setup`, `onAcquire` and `onRelease`. Names starting with `_` are internal and also taken, and so are `Entity`'s private `components`, `byClass` and `callbacks` — a subclass field under one of those names fails to compile with a "separate declarations of a private property" error.

A field an `Entity` subclass assigns in `setup()` cannot be `readonly`: TypeScript allows a write to a readonly field only from the constructor. Declare it with a definite assignment assertion instead.

```ts
import { Entity } from "@yagejs/core";
import { RigidBodyComponent } from "@yagejs/physics";

class Player extends Entity {
  private body!: RigidBodyComponent; // not `readonly`
  setup() {
    this.body = this.add(new RigidBodyComponent({ type: "dynamic" }));
  }
}
```

### State machines

Use `StateMachine` for stored modes with a fixed set of legal transitions.
`defineStates` preserves the state-name union, so unknown targets and calls such
as `go("jmup")` fail type checking.

```ts yage-group="guard"
import { Component, defineStates } from "@yagejs/core";

class GuardBrain extends Component {
  readonly brain = this.stateMachine(
    defineStates({
      patrol: { to: ["alert"] },
      alert: { to: ["patrol"], for: 1, next: "patrol" },
    }),
    "patrol",
  );

  fixedUpdate(dt: number) {
    if (this.seesPlayer()) this.brain.go("alert");
    this.brain.tick(dt);
  }

  private seesPlayer(): boolean {
    return false; // line-of-sight check
  }
}
```

- `go(state)` throws for an undeclared edge, before any hook runs. Naming the current state restarts it when that state lists itself in `to`: the `exit` hook runs, the timer resets, and the `enter` hook runs. Otherwise nothing happens.
- A timed state declares `for` and a different `next` that it can reach. A tick that crosses the boundary discards the excess, so one `tick` advances the current state at most once. Pass the `dt` received by the owning component without applying time scaling again.
- `for` takes a number, or a function called each time the state is entered. A returned value that is not finite and above 0 throws before the transition commits, so the machine stays where it was. Use the function form for a duration that comes from tuning or varies per entry, which a field initializer cannot read.
- `fromAny: true` on a state makes every other state able to enter it without listing it in `to`, for the few states a whole machine falls into such as `hit` or `die`. It adds no edge from the state to itself, and only a top-level state can declare it.
- `canGo(state)` reports whether `go(state)` would move the machine, without throwing. Use it where a callback can arrive after the state moved on, such as an animation finishing after the entity died.
- The first `tick()` runs the initial `enter` hook. `start()` runs it earlier, typically from `onAdd`. Construction and `hydrate()` run no hooks.
- A transition cannot start from inside an `enter` or `exit` hook or an event handler. An exit-hook throw keeps the source state. An enter-hook throw leaves the committed target state in place and propagates.
- `this.stateMachine(...)` attributes hook and handler failures to the component. A standalone `new StateMachine(states, initial)` works in headless code and calls them directly.
- `serialize()` returns `StateMachineSaveData`: `{ state, elapsed }`, plus `parentElapsed` inside a sequence and `duration` for a state that computes its `for`. `hydrate()` restores it without hooks, resuming on a saved computed duration and asking the callback for one only when the snapshot has none. A restored state runs its exit hook on a later transition.
- A machine stored in a component field without a leading underscore appears in Inspector component state as `{ state, elapsed, lastTransition }`, plus `parent` and `parentElapsed` inside a sequence. TypeScript `private` fields are included. Normal underscore and `inspectExclude` rules still apply.
- Use several machines for independent state axes. Keep derived facts as getters. Use the abilities addon when lanes, priorities, input intents, holds, or timed action steps are part of the behavior.

#### Events

`machine.events` holds three tokens typed with that machine's state names:
`changed` (`{ from, to }`), `entered` (`{ state, from }`), and `exited`
(`{ state, to }`). Subscribe with `machine.on(token, handler)`, which returns an
unsubscribe function, or with `this.listen(machine, token, handler)`, which drops
the subscription when the component is removed.

Pass an `events` name to publish them on the entity as well, under
`<name>:changed`, `<name>:entered` and `<name>:exited`. Entity events dispatch by
name, so two machines on one entity need different names. Without a name the
events stay on the machine and reach only `machine.on`.

```ts
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" });
}
// elsewhere: entity.on(brain.mode.events.entered, ...), scene.on(...) too
```

```ts yage-group="guard"
import { AnimationController } from "@yagejs/renderer";

class GuardView extends Component {
  private readonly anim = this.sibling(AnimationController);
  private readonly guard = this.sibling(GuardBrain);

  onAdd() {
    const { brain } = this.guard;
    this.listen(brain, brain.events.entered, ({ state }) =>
      this.anim.play(state),
    );
  }
}
```

Order on a transition: `exit` hook, `exited`, commit, `enter` hook, `entered`,
`changed`. The first entry emits `entered` with `from: null` and no `changed`.
`hydrate()` emits nothing. A handler may not call `go`, `tick`, `start` or
`hydrate` on the machine that called it; it may on another machine.

#### Child states

A state holds a phase sequence by declaring `states` and the `start` phase
entered with it. Names stay in one union, nesting is one level deep, and a
parent is never the current state.

```ts
import { defineStates } from "@yagejs/core";

defineStates({
  idle: { to: ["shoot", "hit"] },
  shoot: {
    to: ["idle", "hit"], // reachable from every child
    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"] },
});
```

- `state` reads the child. `is(name)` is true for the current child and for its parent.
- `go("shoot")` enters `aim`. `go("hit")` from any child runs the child's `exit`, then `shoot`'s, so a sequence cannot outlive the state that holds it.
- A child's `to` names its siblings, its parent, and states at the top level. Naming another parent's child is rejected when the machine is built.
- Children run on the machine's one clock. A parent's own `for` and `next` put a deadline on the whole sequence, and that `next` has to leave the sequence. One tick advances one state per level: the child moves first, then the parent's deadline ends the sequence if it has come due.
- Re-entering a parent always starts at `start`, with its deadline reset; there is no history.
- `entered` and `exited` fire at both levels: parent then child on the way in, child then parent on the way out. `changed` fires once, naming the children.
- `hydrate()` restores the parent from the child's name and rejects a snapshot that names a parent.

### EntityPool

A group of entities cycled by deactivation rather than spawn and destroy. A member is built once and reused, so its Rapier body, Pixi display object and component instances stay allocated between lives.

```ts
import { Component, Entity, EntityPool, type Vec2 } from "@yagejs/core";
import { RigidBodyComponent } from "@yagejs/physics";

class Bullet extends Entity {
  damage = 0;
  setup() {
    /* Transform, GraphicsComponent, RigidBodyComponent, collider */
  }
  // Required for a pooled class. Its parameters become acquire()'s arguments.
  onAcquire(x: number, y: number, dir: Vec2, damage: number) {
    this.damage = damage;
    const rb = this.get(RigidBodyComponent);
    rb.setPosition(x, y);
    rb.setVelocity({ x: dir.x * 900, y: dir.y * 900 });
  }
  onRelease() {
    this.damage = 0;
  } // optional, game-level cleanup
}

// The component that fires owns the pool.
class Gun extends Component {
  private bullets!: EntityPool<Bullet>;

  onAdd() {
    // Members' components resolve scene services during setup().
    this.bullets = new EntityPool(this.scene, Bullet, { prewarm: 32 });
  }

  fire(x: number, y: number, dir: Vec2) {
    const bullet = this.bullets.acquire(x, y, dir, 1); // Bullet
    // ...on impact: this.bullets.release(bullet), or bullet.destroy()
  }
}
```

```ts
import { EntityPool as BaseEntityPool } from "@yagejs/core";
import type {
  EntityPoolOptions as BaseEntityPoolOptions,
  PoolableEntity,
  Scene,
} from "@yagejs/core";

declare class EntityPool<
  T extends PoolableEntity,
  TMax extends number | undefined = undefined,
> extends BaseEntityPool<T, TMax> {
  // Third argument carries { setup } when the class's setup() requires params.
  constructor(
    scene: Scene,
    Class: new () => T,
    options?: EntityPoolOptions<T, TMax>,
  );
  get size(): number; // total members
  get leased(): number; // handed out
  get free(): number; // available
  acquire(
    ...args: Parameters<T["onAcquire"]>
  ): undefined extends TMax ? T : T | undefined; // T when elastic
  forceAcquire(...args: Parameters<T["onAcquire"]>): T;
  release(member: T): void;
  releaseAll(): void;
  dispose(): void; // destroys members; the scene does this on exit
}

interface EntityPoolOptions<
  T extends PoolableEntity,
  TMax extends number | undefined = undefined,
> extends BaseEntityPoolOptions<T, TMax> {
  prewarm?: number; // built up front, parked dormant
  maxSize?: TMax; // total members; unset = elastic
  reclaimPriority?: (member: T) => number; // lowest is reclaimed first
}
```

- Elastic by default: `acquire` grows the pool and returns `T`. With `maxSize`, a saturated `acquire` returns `undefined` and the return type widens to `T | undefined`.
- A capped pool assigned to an unannotated `const` keeps the literal cap in its type (`EntityPool<Bullet, 32>`), which does not assign to `EntityPool<Bullet, number>`. Annotate the field or variable and the cap infers as `number`.
- `forceAcquire` always returns a member. On a saturated capped pool it reclaims the lowest `reclaimPriority` (default: acquired longest ago), running `onRelease` then `onAcquire` in the same call.
- `onAcquire` is required on a pooled class — an inherited one counts. It must be synchronous and non-overloaded, since `acquire`'s signature is derived from it. Declare an empty `onAcquire() {}` when there is nothing to reset.
- Prewarm builds members and runs `setup()`, never `onAcquire`.
- The member is active, in its queries, and past `onEnable` before `onAcquire` runs. Acquire from a component `update`/`fixedUpdate` and the member's components run in that same pass, after the acquirer's. Acquire during Update and it renders the same frame; acquire in Render or EndOfFrame and it first draws on the next one.
- Release cancels scheduled actions, keeps inert state. It cancels the member's `ProcessComponent` (a pending `Process.delay` would otherwise fire unprompted on a later lease), but position, health, animation frame, `timeScale`, and entity listeners all survive a cycle. Reset them in `onAcquire`, and register listeners in `setup()` or drop them in `onRelease`.
- Bookkeeping completes before the hooks run. A throwing `onAcquire` leaves the member leased and active; a throwing `onRelease` still parks it. Both throws are attributed to the entity and propagate.
- Releasing an entity the pool has not leased — a double release, another pool's member — is a reported no-op. `setActive` called from outside does not change who holds the lease.
- Pools belong to their scene and are disposed on exit; `acquire` on a disposed pool throws. Create a pool once the scene has entered, where scene services exist: in the `onAdd()` of the component that uses it, as above. The pool belongs to the scene, not to that component, and lasts until scene exit or `dispose()`.
- A member that picked up a parent while leased (attached via `addChild`) is detached before it goes back into the pool, after `onRelease` has run — so `onRelease` can still read `entity.parent`, but the next lease never inherits a stale one.
- The pool owns its members' lifetimes. `entity.destroy()` on a member releases it back to the pool instead of tearing it down, so retire sites holding a plain `Entity` (collision handlers, `update`, event listeners) need no pool reference and the same code works pooled or not. `isDestroyed` stays `false` for such a member; destroying an entity with a member below it detaches and returns that member. Only `dispose()` destroys members.
- Pools and their members are runtime objects. Save durable game facts through an explicit state root, then reconstruct and prewarm pools during scene setup.
- A released member is alive and `isDestroyed` is `false`, so a stored reference to one silently follows the entity into its next life. Store `entity.handle()` instead when something else owns the release.
- The physics collision drain captures both sides of every pair before running any handler, so a pair naming an entity a handler released is dropped instead of reaching whoever acquired it next.

### Entity handles

`entity.handle()` returns an `EntityHandle<T>`: a reference that stops resolving when that entity's life ends. Read it through `.current`.

```ts
import { Component, Entity, type EntityHandle } from "@yagejs/core";

class Enemy extends Entity {}

class TurretAim extends Component {
  private target?: EntityHandle<Enemy>;

  onSpotted(enemy: Enemy) {
    this.target = enemy.handle();
  }

  update() {
    const enemy = this.target?.current; // undefined once that enemy is gone
    if (enemy) this.aimAt(enemy);
  }

  private aimAt(enemy: Enemy) {
    /* turn the barrel toward enemy */
  }
}
```

```ts
import type { Entity, EntityHandle as BaseEntityHandle } from "@yagejs/core";

interface EntityHandle<
  out T extends Entity = Entity,
> extends BaseEntityHandle<T> {
  readonly current: T | undefined;
}
```

- Rule of thumb: use a handle whenever pooled entities are involved — a member can be retired from anywhere (`destroy()` in its own collision handler releases it), so a stored plain reference goes stale silently. A plain reference is fine for entities that live as long as the scene, or when the code storing the reference also controls when the entity goes away.
- `.current` means "same life", not "currently active": an entity turned off with `setActive(false)` still resolves.
- A life ends on `destroy()`, on scene teardown, on every path that ends a member's lease — `release`, `releaseAll`, a `forceAcquire` reclaim — and on `dispose()`, which destroys the members outright. A member's children end their lives with it, so a handle on a pooled entity's hitbox expires too.
- `entity.generation` is the counter behind it: 0 for a fresh entity, increased whenever a life ends. Compare it for equality — a destruction cascade can advance it more than once, so it does not count lives. Public read, engine write. Inspector world-entity snapshots include it.
- `handle()` on a pool member the pool is not currently lending out returns a handle that never resolves, and warns in dev builds. The caller is holding a stale reference, so a handle from it would come alive at the next acquisition.
- Handles are created by `entity.handle()` only; `EntityHandle` is a type, not a constructor. `T` is output-only, so an `EntityHandle<Enemy>` is assignable to `EntityHandle<Entity>` and not the other way round.

### Events

| Export                 | Purpose                                                    |
| ---------------------- | ---------------------------------------------------------- |
| `EventBus<E>`          | Typed pub/sub (`on`, `once`, `emit`, `clear`, `tap`)       |
| `EventToken<T>`        | Typed token for entity and scene events                    |
| `defineEvent<T>(name)` | Create an event token; the name must be a non-empty string |

- `bus.on` returns an unsubscribe bound to that registration: the same function registered twice fires twice, and each unsubscribe removes its own entry, once.
- `bus.tap(observer)` receives every emit before its handlers run, inside the same error boundary as a handler: a throwing observer is recorded, rethrown, and stops that emit's handlers. Tooling only (the Inspector event log uses it).
- Entity and scene events dispatch by the token's name, not by the token object: two `defineEvent` calls with one name are one channel, and their payload types are not checked against each other. Prefix names with the owning module (`"inventory:item-added"`). In dev builds the second definition of a name logs a warning.

`EngineEvents` (the typed map used by `EventBusKey`):

| Event                                                 | Payload                                                                                                                                                      |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `entity:created`                                      | `{ entity }`                                                                                                                                                 |
| `entity:destroyed`                                    | `{ entity, scene: Scene }` — fires on the end-of-frame flush after `destroy()` and once per entity on scene teardown, before `scene:popped`/`scene:replaced` |
| `component:added`                                     | `{ entity; component }`                                                                                                                                      |
| `component:removed`                                   | `{ entity; componentClass }`                                                                                                                                 |
| `scene:pushed` / `scene:popped`                       | `{ scene }`                                                                                                                                                  |
| `scene:replaced`                                      | `{ oldScene; newScene }`                                                                                                                                     |
| `scene:transition:started` / `scene:transition:ended` | `{ kind; fromScene; toScene }`                                                                                                                               |
| `scene:loading:progress`                              | `{ scene; ratio }`                                                                                                                                           |
| `scene:loading:done`                                  | `{ scene }`                                                                                                                                                  |
| `engine:started` / `engine:stopped`                   | `undefined`                                                                                                                                                  |
| `screen:fullscreen`                                   | `{ active: boolean }` — emitted by `RendererPlugin` on `fullscreenchange` / `webkitfullscreenchange`                                                         |
| `screen:orientation`                                  | `{ type: OrientationType }` — emitted by `RendererPlugin` on `screen.orientation.change` (or `orientationchange` fallback)                                   |

`entity` in the `entity:*` and `component:*` payloads is the live `Entity` (`entity.tags`, `entity.get(...)` work in the handler). The `scene` fields are `{ name }`, except `scene:loading:*` and `entity:destroyed`, which carry the `Scene`. The destruction payload retains its owning scene after the entity detaches.

### Scene Events

`Scene.on(token, handler)` subscribes to a typed event at the scene level. Handlers fire for **both** scene-emitted events (`scene.emit(token, data)`) and entity events that bubble up (`entity.emit(token, data)`). The handler signature distinguishes the two via an optional second arg:

```ts yage-context="scene"
import { defineEvent, type Entity } from "@yagejs/core";

const DamagedEvent = defineEvent<{ amount: number }>("damaged");

// Inside a scene:
scene.on(DamagedEvent, (data: { amount: number }, entity?: Entity) => {
  if (entity) {
    // bubbled — `entity` is the source that called entity.emit(DamagedEvent, ...)
    console.log(`${entity.name} took ${data.amount}`);
  } else {
    // scene-emitted via scene.emit(DamagedEvent, ...)
    console.log(`scene-wide damage event: ${data.amount}`);
  }
});

scene.emit(DamagedEvent, { amount: 5 }); // handler runs with entity = undefined
const someEntity = scene.spawn("goblin");
someEntity.emit(DamagedEvent, { amount: 10 }); // handler runs with entity = someEntity
```

`Scene.on` returns an unsubscribe function. The handler param is `(data, entity?)` regardless of which side emitted — game code should check `entity` to decide whether to read source state.

Scene-level subscriptions are released when the scene exits, together with its entities. Game code subscribes from a component through `this.listenScene(token, handler)`, which also unsubscribes when the component is removed. `onEnter` may connect an event to a component method in one line (`this.on(PlayerDied, () => player.get(Respawn).respawn())`) but holds no state or rules. A scene instance pushed again starts with no subscriptions.

`Scene.registerScoped<T>(key: ServiceKey<T>, value: T)` (public) attaches a scene-scoped service resolvable via `Component.use(key)`, and via `Scene.use(key)` / `Scene.service(key)`. Both are public and scope-aware — scene scope first, then engine — so any holder of a scene reference resolves through them, not only the scene subclass: an entity's `setup()` calls `this.scene.use(RandomKey)`. `use` throws when the key resolves nowhere; `service` returns a lazy proxy that resolves on first property access. Plugins call it from `beforeEnter` to register per-scene infrastructure. It is not for game state: score, lives or a run timer live in a component on a host entity spawned with a `key` and found with `scene.findByKey` (`core-concepts.md` → Game State). Every key registered this way is auto-unregistered on scene exit (after `onExit` and plugin `afterExit` hooks), so scenes don't leak services into one another. `Scene.tryResolveScoped<T>(key)` (public) reads a scene-scoped service without engine-scope fallback, returning `undefined` when absent. Use it in systems that iterate scenes.

### SceneTime — hitstop, slow motion, bullet time, freeze frames

Per-scene arbitration for competing time effects. The engine registers one instance per scene under the scene-scoped `SceneTimeKey`; resolve via `Component.use(SceneTimeKey)` / `Scene.use(SceneTimeKey)`, or `scene.tryResolveScoped(SceneTimeKey)` from a System.

| Member                                                     | Purpose                                                                                                                                                                                                                                                                                                                                             |
| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `scaleBy(factor, { for?, key?, excludeUpdates?, label? })` | Add a scale request. `factor` finite and > 0 (> 1 = speed-up; physics catch-up capped at ~8 sub-steps/frame). Returns `TimeEffectHandle { active, release() }` (idempotent)                                                                                                                                                                         |
| `freezeFor(duration, { key?, excludeUpdates?, label? })`   | ×0 scene request for `duration` real-time seconds; exclusions keep selected updates running while physics remains frozen                                                                                                                                                                                                                            |
| `scaleEntityBy(entity, factor, { for?, key?, label? })`    | Scale one entity's component updates, processes, and particle emitters; does not affect physics                                                                                                                                                                                                                                                     |
| `freezeEntityFor(entity, duration, { key?, label? })`      | ×0 request for one entity's updates; expires on raw scene time and does not affect physics                                                                                                                                                                                                                                                          |
| `effectiveScale`                                           | `scene.timeScale × Π(channel winners)` — what physics and scene-pool processes run at                                                                                                                                                                                                                                                               |
| `elapsed`                                                  | Simulation seconds elapsed under `effectiveScale`, accrued once per rendered frame; held by stack pause, `timeScale = 0`, and freeze requests; starts at 0 on scene entry and is not saved                                                                                                                                                          |
| `fixedElapsed`                                             | Simulation seconds accrued on the fixed timestep — one `fixedTimestep × effectiveScale` increment per fixed step; same holds as `elapsed` (stack pause, `timeScale = 0`, freeze); stamp and compare gameplay times against this from fixed-step code, never against `elapsed`. Whole-scene reading: ignores `entity.timeScale` and `excludeUpdates` |
| `effectiveScaleForUpdates(entity)`                         | Scene scale after exclusions, multiplied by entity-request winners; `entity.timeScale` is composed on top by the update pipeline                                                                                                                                                                                                                    |
| `isFrozen`                                                 | `effectiveScale === 0`                                                                                                                                                                                                                                                                                                                              |
| `activeLabels`                                             | Display labels of active requests (`label` option, defaults to `key`)                                                                                                                                                                                                                                                                               |

Composition: each `key` is a channel. Within a channel, the latest active request wins, and older still-active entries apply again when it ends. Across channels, winners multiply. Entity channels are independent per target and multiply after the scene request result. An unkeyed call is its own anonymous channel. `scene.timeScale` and `entity.timeScale` remain base values; the service never writes them. Durations age on raw frame time at the start of each frame, only while the scene is active, so an entity freeze can expire without that entity updating. A stack-paused scene holds its effects. Zero-duration requests return an inactive handle. Entity-scoped requests and `excludeUpdates` entries apply only to the entity life that was current when the request started; pool release ends that association before the entity can be reused. All requests release on scene exit and are runtime-only. `excludeUpdates` and entity requests cover component updates, the entity's `ProcessComponent`, and its particle emitters. They do not provide per-body time: scene freeze still freezes physics, while an entity request leaves its target's rigid body running at scene speed. The two elapsed readings differ in cadence: `elapsed` advances once per rendered frame at the start of the frame, `fixedElapsed` advances once per fixed step before the `FixedUpdate` phase runs. Stamp and compare against the same reading. Inspector scene snapshots report `effectiveTimeScale` and `frozen`.

### Math

| Export             | Purpose                                                                                                                                                                                                                                                                                                                    |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Vec2`             | Immutable 2D vector (`add`, `sub`, `scale`, `normalize`, `lerp`, `dot`, `distance`, static `moveTowards`)                                                                                                                                                                                                                  |
| `Vec2Buffer`       | Caller-owned mutable coordinates for `Into` results; `new Vec2Buffer(x = 0, y = 0)`, writable `x` / `y`, `set(x, y): this`                                                                                                                                                                                                 |
| `Transform`        | Mutable position/rotation/scale component (`setPosition`, `translate`, `rotate`); `worldPosition` / `worldRotation` / `worldScale` are lazily computed and cache-invalidate on local mutation or reparenting; `localToWorld(point)` / `worldToLocal(point)` convert a point between the entity's own space and the world's |
| `MathUtils`        | `lerp`, `inverseLerp`, `lerpAngle`, `shortestAngleBetween`, `pingPong`, `smoothDamp`, `clamp`, etc.                                                                                                                                                                                                                        |
| `SmoothDampResult` | `{ value, velocity }` returned by `MathUtils.smoothDamp()`                                                                                                                                                                                                                                                                 |

Math signatures:

```ts
import { MathUtils as BaseMathUtils, Vec2 as BaseVec2 } from "@yagejs/core";
import type { SmoothDampResult, Vec2Like } from "@yagejs/core";

// MathUtils is a plain object; these are its members.
type MathUtilsObject = typeof BaseMathUtils;
interface MathUtils extends MathUtilsObject {
  lerp(a: number, b: number, t: number): number;
  inverseLerp(a: number, b: number, v: number): number; // clamped 0..1
  lerpAngle(a: number, b: number, t: number): number; // radians, shortest path around +/-PI
  shortestAngleBetween(a: number, b: number): number; // signed delta in [-PI, PI]
  pingPong(t: number, length: number): number; // bounces in [0, length]
  smoothDamp(
    current: number,
    target: number,
    velocity: number,
    smoothTime: number,
    deltaTime: number,
    maxSpeed?: number,
  ): SmoothDampResult;
}

declare class Vec2 extends BaseVec2 {
  static lerp(a: Vec2Like, b: Vec2Like, t: number): Vec2;
  static moveTowards(
    current: Vec2Like,
    target: Vec2Like,
    maxDelta: number,
  ): Vec2;
}
```

`Vec2` is the default for values you keep or share. Its vector operations return
immutable values. For repeated calculations, allocate a `Vec2Buffer` once and
pass it as the first argument to an `Into` method:

```ts
import { Vec2 as BaseVec2 } from "@yagejs/core";
import type { Vec2Buffer, Vec2Like } from "@yagejs/core";

declare class Vec2 extends BaseVec2 {
  static copyInto(out: Vec2Buffer, source: Vec2Like): Vec2Buffer;
  static addInto(out: Vec2Buffer, a: Vec2Like, b: Vec2Like): Vec2Buffer;
  static subInto(out: Vec2Buffer, a: Vec2Like, b: Vec2Like): Vec2Buffer;
  static scaleInto(
    out: Vec2Buffer,
    source: Vec2Like,
    scalar: number,
  ): Vec2Buffer;
  static multiplyInto(out: Vec2Buffer, a: Vec2Like, b: Vec2Like): Vec2Buffer;
  static normalizeInto(out: Vec2Buffer, source: Vec2Like): Vec2Buffer;
  static lerpInto(
    out: Vec2Buffer,
    a: Vec2Like,
    b: Vec2Like,
    t: number,
  ): Vec2Buffer;
  static rotateInto(
    out: Vec2Buffer,
    source: Vec2Like,
    radians: number,
  ): Vec2Buffer;
  static fromAngleInto(
    out: Vec2Buffer,
    radians: number,
    length?: number,
  ): Vec2Buffer;
  static moveTowardsInto(
    out: Vec2Buffer,
    current: Vec2Like,
    target: Vec2Like,
    maxDelta: number,
  ): Vec2Buffer;
}
```

Each method overwrites and returns the supplied buffer without constructing a
`Vec2`. The output may also be either input. `fromAngleInto` defaults `length`
to `1`; the formulas and edge behavior match the immutable methods. A `Vec2`
is not a valid output. Buffers do not validate numbers; pure math results are
undefined for non-finite inputs.

For `smoothDamp`, pass the returned `velocity` into the next frame. `smoothTime`
and `deltaTime` must use the same unit: pass the `dt` (seconds) the engine
gives you and express `smoothTime` in seconds. `maxSpeed` is in units per second.

### Transform reads and writes

```ts
import { Transform as BaseTransform } from "@yagejs/core";
import type { Vec2Buffer } from "@yagejs/core";

declare class Transform extends BaseTransform {
  setPosition(x: number, y: number): void;
  setWorldPosition(x: number, y: number): void;
  getPositionInto(out: Vec2Buffer): Vec2Buffer;
  getWorldPositionInto(out: Vec2Buffer): Vec2Buffer;
  getScaleInto(out: Vec2Buffer): Vec2Buffer;
  getWorldScaleInto(out: Vec2Buffer): Vec2Buffer;
}
```

Scalar writes and `Into` reads do not construct `Vec2` values. Each `Into`
read overwrites and returns the caller's buffer. Later transform changes do
not update that buffer; call the getter again to refresh it.

`position`, `worldPosition`, `scale`, and `worldScale` return immutable
snapshots, created on demand. Repeated reads preserve identity until mutation,
and previously returned snapshots never change. Constructor options are copied.
Assigning a `Vec2` to `position` or `scale` preserves that value's identity;
assigning `worldPosition` does so on a root. Root local/world getters share
the same snapshot.

A rotation turns the entity's own space about the entity's own position, and
carries every descendant around that same point. `Transform` has no pivot, so
the point an entity turns about is always its own position. To turn an entity
about a different point, put that point on a parent entity and rotate the
parent. To turn the artwork about a different point while the entity stays
where it is, use the visual component's `anchor` or, on `GraphicsComponent`,
`pivot` — see `@yagejs/renderer`.

Transform position, scale, and rotation writes require finite numbers and
reject invalid inputs or non-finite computed local values before storing the
operation's values. Zero and negative scale are legal. Read-only conversions
and derived world values do not validate non-finite results.

### Points in an entity's own space

`localToWorld(point)` scales a point by `worldScale`, turns it by `worldRotation`, and offsets it by `worldPosition` — the same composition a child transform goes through. `worldToLocal(point)` is the inverse. Both take a `Vec2Like` and return a `Vec2`.

```ts
import { Entity, Transform, type Vec2 } from "@yagejs/core";

class Bullet extends Entity {
  setup({ position }: { position: Vec2 }) {
    this.add(new Transform({ position }));
  }
}

class Gun extends Entity {
  fire(): void {
    const muzzle = this.get(Transform).localToWorld({ x: 24, y: -6 });
    this.scene.spawn(Bullet, { position: muzzle });
  }
}
```

Use it for an offset authored beside the entity — a muzzle, a spawn point — so it follows the entity however the parent chain turns or scales it. On an axis whose world scale is 0 no local point maps back, so `worldToLocal` is non-finite there; check `Number.isFinite` if such a transform can reach you.

### Scale inheritance

`Transform.worldScale` composes through the parent chain (`parent.worldScale * local.scale`), the same way `worldPosition` and `worldRotation` do. `DisplaySystem` reads `worldScale` each Render phase, so flipping a parent flips every descendant sprite automatically — useful for multi-layer characters (head + body + outfit) where every layer must flip together.

`Transform.worldToLocal(point)` reverses the entity's world position, rotation and scale, so a world-space point (a pointer, a hit) can be read in the entity's own space whatever its parent chain does. On an axis whose world scale is 0 the result is non-finite.

Gotcha: while a parent's world scale is 0 on an axis (common mid scale-tween), assigning `worldPosition` or calling `setWorldPosition` cannot move the child along that axis. The write keeps the child's local value on that axis unchanged and emits a dev-mode warning once per transform. The child stays at the parent's origin on that axis until the scale is non-zero again.

```ts
import { Entity, Transform } from "@yagejs/core";
import { SpriteComponent } from "@yagejs/renderer";

class Character extends Entity {
  setup() {
    this.add(new Transform()); // parent — drives facing
    const body = this.spawnChild("body");
    body.add(new Transform());
    body.add(new SpriteComponent({ texture: "body.png" }));
    const head = this.spawnChild("head");
    head.add(new Transform({ position: { x: 0, y: -20 } }));
    head.add(new SpriteComponent({ texture: "head.png" }));
  }

  faceLeft(): void {
    this.get(Transform).setScale(-1, 1); // mirrors body + head together
  }
  faceRight(): void {
    this.get(Transform).setScale(1, 1);
  }
}
```

Negative scale on a child still composes — a child with `setScale(-1, 1)` under a parent already at `(-1, 1)` ends up at `worldScale = (1, 1)` (un-mirrored). The same composition applies to positive non-unit scales (a parent at `2x` zooms its whole subtree).

### Processes

| Export                  | Purpose                                                                                                                                                                                                                                                              |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Process`               | Ticked action, advanced by whichever clock it is scheduled on; `Process.delay(seconds, cb)`; `.elapsed` — seconds ticked so far, scaled by the caller's timeScale                                                                                                    |
| `ProcessComponent`      | Entity component managing processes and slots                                                                                                                                                                                                                        |
| `ProcessSlot`           | Reusable restartable handle (cooldowns, effects)                                                                                                                                                                                                                     |
| `Tween`                 | Static factory: `to`, `custom`, `vec2`, `stagger`                                                                                                                                                                                                                    |
| `Sequence`              | Chainable step builder: `then`, `wait`, `call`, `parallel`, `loop`, `repeat`; `build()` returns the wrapping `Process` for a runner to drive                                                                                                                         |
| `TimerEntity`           | Pre-built entity with ProcessComponent API                                                                                                                                                                                                                           |
| `makeEntityScopedQueue` | `(entity, options?: { clock?: ProcessClock }) => ScopedProcessQueue`. Routes through the entity's `ProcessComponent`, adding one when the entity has none. `run(p): Process` enqueues, `cancelAll()` cancels only what this queue enqueued                           |
| `ProcessSystem`         | Engine-level process pools, resolved with `context.resolve(ProcessSystemKey)`. `add(p, options?): Process` (engine-global), `addForScene(scene, p, options?): Process`, `cancel(tag?)`, `cancelForScene(scene, tag?)`. Both `options` are `{ clock?: ProcessClock }` |
| `makeSceneScopedQueue`  | `(processSystem, scene, options?: { clock?: ProcessClock }) => ScopedProcessQueue`. Routes through `ProcessSystem.addForScene`, so its processes pause and scale with the scene. Same `run`/`cancelAll` contract                                                     |
| `makeGlobalScopedQueue` | `(processSystem, options?: { clock?: ProcessClock }) => ScopedProcessQueue`. Routes through `ProcessSystem.add` — global time scale only, no per-scene pause gating. Same `run`/`cancelAll` contract                                                                 |

Decision matrix:

| Need                                                  | Use                                                                     |
| ----------------------------------------------------- | ----------------------------------------------------------------------- |
| Wait N seconds then run a callback                    | `Process.delay()`                                                       |
| Cooldown / restartable timer (`completed`, `restart`) | `pc.slot()`                                                             |
| Animate one property A → B                            | `Tween.to()` / `.vec2()`                                                |
| Interpolate a number from→to with a custom setter     | `Tween.custom(setter, from, to, duration, easing?)`                     |
| Cascade a tween across an array (staggered starts)    | `Tween.stagger(items, (item, i) => Process, stepSeconds)` → `Process[]` |
| Arbitrary per-frame logic (no interpolation)          | `new Process({ update })`                                               |
| Multi-step "do this, then this, then this"            | `Sequence`                                                              |
| Run several animations together                       | `Sequence.parallel()`                                                   |
| Multi-point or non-monotonic animation curves         | `KeyframeAnimator`                                                      |
| Fire discrete events at specific times                | `KeyframeAnimator` keyframe `event`                                     |

Tag processes with `pc.run(p, { tags: ["vfx"] })` then cancel groups with `pc.cancel("vfx")`. Processes and slots auto-cancel on entity destroy via `ProcessComponent.onDestroy()`, and on `EntityPool` release too — a pending `Process.delay` scheduled before release would otherwise fire on the next lease.

Durations are in seconds and must be finite and > 0: `new Process({ duration })`, `Process.delay`, `Sequence.wait`, `Tween.to`/`custom`/`vec2`, `pc.slot({ duration })` and `slot.start({ duration })` throw on anything else, as do non-finite tween endpoints. `Tween.stagger`'s `stepSeconds` is the exception: finite and >= 0, where `0` starts every item at once. A duration completes on the tick that reaches it — `Process.delay(0.25)` on the fixed clock ends on step 15 of 1/60 s, not 16, even though the float sum of the steps lands a hair short. `elapsed` still reports the real accumulated time, overshoot included, and a looping process carries only the overshoot into the next pass.

`slot.start(overrides)` applies its overrides to that run alone: the next bare `start()` uses the config the slot was created with, and `slot.tags` returns the running slot's tags, then the configured tags once it completes. `start()` on a running slot is a no-op, so overrides passed there are dropped with it — use `restart(overrides)`. A slot with `loop: true` never completes, so its `onComplete` never runs; for a repeating timer, loop a sequence: `pc.run(new Sequence().wait(2).call(spawnWave).loop().build())`.

`new Process({ onCancel, onReset })`: `onCancel` runs when `cancel()` stops the process before it completed, `onReset` when it is reset for a re-run. Implement them on a process that owns other processes or keeps state outside its `update` callback. `Sequence` supplies both, so cancelling a sequence cancels the step processes it started — including a `Tween` instance the game built, passed to `.then()`/`.parallel()`, and still holds — and a built sequence (or a `Tween.stagger` output) can be reused as a step of another sequence.

Clocks (`ProcessClock = "frame" | "fixed"`): entity processes and slots tick on rendered-frame time by default (`ProcessSystem`, `Phase.Update`, priority 500). `pc.run(p, { clock: "fixed" })` / `pc.slot({ clock: "fixed", ... })` tick on the fixed timestep instead (`ProcessFixedUpdateSystem`, `Phase.FixedUpdate`, priority 500 — after physics, before component `fixedUpdate`). Use `"fixed"` for gameplay timing that must match a fixed-step simulation (attack windows, cooldowns); keep visuals on `"frame"`. Both clocks share pause gating and global/scene/entity time scaling. A slot's clock is fixed at creation — `start()`/`restart()` overrides exclude it. `makeEntityScopedQueue(entity, { clock: "fixed" })` puts every process that queue enqueues on the fixed timestep; the default is `"frame"`. The clock is read when the queue is created, so `ScopedProcessQueue.run(p)` takes none, and a second clock needs a second queue with its own `cancelAll()`. Enqueueing a `Process` already scheduled on that entity's `ProcessComponent` keeps the clock it was scheduled with. `ProcessSystem.add(p, { clock })` and `ProcessSystem.addForScene(scene, p, { clock })` take the same option (default `"frame"`), and so do `makeGlobalScopedQueue(processSystem, { clock })` and `makeSceneScopedQueue(processSystem, scene, { clock })` — use them for timing that belongs to a scene or to the engine rather than to one entity, such as a round timer or a wave spawner that outlives any entity that would otherwise host it. A scene pool on either clock is gated by scene pause and scaled by the scene's effective scale. A global pool on either clock runs under `ProcessSystem.timeScale` alone and drains once per pass rather than once per active scene: the frame pool once per rendered frame, the fixed pool once per fixed step. `ProcessSystem.cancel(tag?)` and `cancelForScene(scene, tag?)` cover both clocks. `KeyframeAnimationDef.clock` (default `"frame"`) picks the clock for one `KeyframeAnimator` animation — the animator schedules the track itself, so the choice lives on the def.

`pc.removeSlot(slot): boolean` cancels and unregisters one owned `ProcessSlot`.
It returns `false` for a foreign or already-removed slot. Use it when a
component permanently discards a dynamically-created slot; `slot.cancel()`
alone keeps the reusable slot registered with its `ProcessComponent`.

`process.toPromise(): Promise<void>` resolves when the current run ends —
completion, `cancel()`, or a reset for a re-run. It is how an `async` caller
waits for a process it scheduled, and it resolves immediately on a process that
has already completed. The promise carries no result, so read the state the
process wrote once it resolves.

```ts
import {
  Component,
  ProcessComponent,
  Scene,
  SceneManagerKey,
  Tween,
} from "@yagejs/core";
import { GraphicsComponent } from "@yagejs/renderer";

class Level2 extends Scene {
  readonly name = "level-2";
}

class ExitDoor extends Component {
  private readonly pc = this.sibling(ProcessComponent);
  private readonly overlay = this.sibling(GraphicsComponent); // covers the screen

  async leave(): Promise<void> {
    const fade = this.pc.run(
      Tween.custom((v) => (this.overlay.alpha = v), 0, 1, 0.4),
    );
    await fade.toPromise();
    await this.use(SceneManagerKey).replace(new Level2());
  }
}
```

### Animation

Keyframe-based property animation on top of `ProcessComponent`. Runs multiple named animations concurrently; values interpolate between keyframes via an easing function and are pushed to a user-supplied setter.

| Export                                 | Purpose                                                                                |
| -------------------------------------- | -------------------------------------------------------------------------------------- |
| `KeyframeAnimator<T>`                  | Component hosting named keyframe animations (`play`, `stop`, `stopAll`, `isPlaying`)   |
| `Keyframe<T>`                          | `{ time, data, easing?, event? }` — single control point                               |
| `KeyframeAnimationDef<T>`              | `{ keyframes, setter?, clock?, loop?, speed?, duration?, easing?, onEnter?, onExit? }` |
| `createKeyframeTrack<T>(options)`      | Factory that returns a `Process` driving a single track                                |
| `interpolate<T>(from, to, t, easing?)` | Blend two `Interpolatable` values                                                      |
| `Interpolatable`                       | `number \| Vec2Like` — registered interpolation types                                  |

```ts yage-context="entity"
import { KeyframeAnimator, ProcessComponent, Transform } from "@yagejs/core";

entity.add(new ProcessComponent());
const anim = entity.add(
  new KeyframeAnimator({
    bob: {
      keyframes: [
        { time: 0, data: 0 },
        { time: 0.5, data: 10 },
        { time: 1, data: 0 },
      ],
      setter: (v) => {
        const t = entity.get(Transform);
        t.setPosition(t.position.x, v as number);
      },
      loop: true,
    },
  }),
);
anim.play("bob");
```

`KeyframeAnimator` requires `ProcessComponent` on the same entity. Each keyframe's `time` is in seconds along the track.

A track needs at least 2 keyframes to interpolate between, each with a finite `time` and sorted ascending (equal times are allowed); `duration` (default: the last keyframe's time) and `speed` must be finite and > 0. `createKeyframeTrack` throws on anything else, and so does `KeyframeAnimator.play` for the def it is given. A first keyframe past time 0 is allowed: the track holds that keyframe's value through the lead-in.

`setter` is **optional** — omit it for "pure timeline" animations that only
fire keyframe `event` callbacks (cutscenes, audio cues, gameplay beats):

```ts yage-context="component"
import { KeyframeAnimator } from "@yagejs/core";
import { AudioManagerKey } from "@yagejs/audio";

const audio = this.use(AudioManagerKey);

new KeyframeAnimator({
  intro: {
    keyframes: [
      { time: 0, data: 0, event: () => audio.play("step") },
      { time: 0.25, data: 0, event: () => audio.play("step") },
      { time: 0.5, data: 0, event: () => audio.play("door") },
    ],
    // no setter — only the events matter
  },
});
```

Playback advances on `def.clock` (`ProcessClock`, default `"frame"`).
`clock: "fixed"` schedules the track on the fixed timestep through
`ProcessFixedUpdateSystem`. Use it for timing that must stay in step with a
fixed-step simulation — most often a setter-less timeline whose `event`
callbacks drive gameplay, so its beats land at the same simulation time every
run. The choice is per animation, so one animator can hold a frame-clock
visual and a fixed-clock timeline. A setter on `"fixed"` is written on fixed
steps, so a rendered frame that runs no fixed step shows the previous value.

`KeyframeAnimationDef.setter` is declared with method syntax so it's
contravariance-friendly: a `Record<string, KeyframeAnimationDef<number>>`
flows into the constructor unchanged, no `as` cast or widening helper needed.

### Randomness

Seeded per-scene RNG. `RandomKey` is a scene-scoped `ServiceKey<RandomService>`;
resolve it in a Component with `this.use(RandomKey)`. It stays deterministic
under a pinned seed and replays; `Math.random()` does not, so using it breaks
replay determinism.

```ts yage-context="component"
import { RandomKey } from "@yagejs/core";

const min = 1;
const max = 6;
const array = ["red", "green", "blue"];

const rng = this.use(RandomKey);
rng.float(); // [0, 1)
rng.range(min, max); // float in [min, max)
rng.int(min, max); // integer in [min, max], inclusive
rng.pick(array); // random element of a non-empty array
rng.shuffle(array); // shuffle in place, returns the same array
rng.getSeed(); // current seed
```

`engine.sceneRandom` is the engine's `SceneRandomSource` (DI:
`SceneRandomSourceKey`). It creates each scene's RNG and owns the seed:

```ts
import { SceneRandomSource as BaseSceneRandomSource } from "@yagejs/core";
import type { RandomService } from "@yagejs/core";

declare class SceneRandomSource extends BaseSceneRandomSource {
  setSeed(seed: number): void; // reseed every scene on the stack and every later one
  clearSeed(): void; // undo setSeed for scenes that enter later
  createSceneRandom(): RandomService; // the engine calls this as each scene enters
}
```

A scene RNG starts from the `setSeed` seed when one is set, else from
`DebugPlugin`'s `deterministicSeed`, else from a fresh random seed. `setSeed`
converts the seed with `normalizeSeed` and throws on `NaN` or `Infinity`.
`clearSeed()` leaves scenes on the stack running their current sequence; a
scene that enters later starts from `deterministicSeed` or a fresh seed again.
`inspector.setSeed(seed)` calls `engine.sceneRandom.setSeed(seed)`.

`globalRandom` is a process-wide `RandomService` for boot-time or cross-scene
code that runs outside any scene. `setSeed` does not reseed it, so keep
replay-critical rolls on the scene RNG (`RandomKey`).

### Preloading a Scene Ahead of Time

```ts yage-context="engine"
import { Scene } from "@yagejs/core";
import type { UIProgressBar } from "@yagejs/ui";

class Level2 extends Scene {
  readonly name = "level-2";
}

declare const bar: UIProgressBar;
const level2 = new Level2();
const scenes = engine.scenes;

await scenes.preload(level2, (ratio) => bar.update({ value: ratio }));
await scenes.replace(level2);
```

`SceneManager.preload(scene, onProgress?)` loads `scene.preload` through the
asset manager and marks the scene. The next `push`/`replace` of that scene
consumes the mark instead of loading again, so the manifest is counted once
and one `unload` per handle frees it. `LoadingScene` uses this method. A scene
preloaded and never pushed keeps its references until `assets.clear()`.

### Pause on Tab Blur

```ts yage-context="scene-enter"
import { SceneManagerKey } from "@yagejs/core";

const scenes = this.context.resolve(SceneManagerKey);

scenes.autoPauseOnBlur = true; // default: false
```

When enabled, `SceneManager` sets `scene.paused = true` on every scene in `activeScenes` on `document.hidden === true`, and restores them on `hidden === false`. Affected scenes get `onPause` on blur and `onResume` on focus. Only scenes paused by this mechanism are restored — user-paused scenes (manual `scene.paused = true` or `pauseBelow` cascade) are never touched, and get neither hook. Toggling the flag off mid-blur unpauses immediately. No-op in non-browser environments.

`onPause`/`onResume` fire on every effective pause transition, i.e. whenever `scene.isPaused` flips, whatever the source: a `pauseBelow` scene pushed above, manual `scene.paused = true`/`false`, or blur auto-pause. Writes that don't change the effective state fire nothing: repeated assignments, flag flips masked by a stack pause, and writes before the scene is pushed. Pushing a scene whose `paused` flag is already true fires `onPause` on entry — this is how you start a scene paused. Do NOT write `scene.paused` from inside a lifecycle hook (`onEnter`/`onExit`/`onPause`/`onResume`): the write races the stack transition's own pause diff, so the hooks can fire twice or unpaired. A dev-mode warning flags it.

### Scene Transitions

| Export                                     | Purpose                                                                   |
| ------------------------------------------ | ------------------------------------------------------------------------- |
| `SceneTransition`                          | Interface: `duration`, `begin?`, `tick`, `end?`                           |
| `SceneTransitionContext`                   | `elapsed`, `kind`, `engineContext`, `fromScene`, `toScene`                |
| `SceneTransitionKind`                      | `"push" \| "pop" \| "replace"`                                            |
| `SceneTransitionOptions`                   | `{ transition?: SceneTransition \| null }`                                |
| `resolveTransition(callSite, destination)` | `null` skips; otherwise call-site → `scene.defaultTransition` → undefined |

Core ships the transition contract + orchestration only. Concrete transitions (`fade`, `flash`, `crossFade`) live in `@yagejs/renderer`.

`SceneManager.push/pop/replace` accept `{ transition }`. `Scene.defaultTransition` provides a per-scene default. `Scene.isTransitioning` and `SceneManager.isTransitioning` reflect active transition state.

Events: `scene:transition:started { kind, fromScene, toScene }`, `scene:transition:ended { kind, fromScene, toScene }` (fromScene/toScene may be `undefined`).

`SceneManager.pop()` returns `Promise<Scene | undefined>`: the popped scene,
or `undefined` when the stack was empty.

#### Reentrant scene swaps

`push`/`pop`/`replace`/`popAll` are safe to call from inside a lifecycle hook
(`onEnter`, `onExit`, `onPause`, `onResume`, or a `beforeEnter`/`afterExit`
hook). The call is queued on the manager's internal pending chain and runs
after the current mutation finishes; the returned promise resolves when the
deferred operation completes.

```ts
import { Scene, SceneManagerKey } from "@yagejs/core";

class GameScene extends Scene {
  readonly name = "game";
}

class TitleScene extends Scene {
  readonly name = "title";

  constructor(private readonly skipToGame: boolean) {
    super();
  }

  onEnter() {
    // Safe — `replace` is queued and runs after TitleScene's onEnter returns.
    if (this.skipToGame) {
      void this.use(SceneManagerKey).replace(new GameScene());
    }
  }
}
```

Dev builds emit a `console.warn` because reentrant swaps are usually a smell
(an `onEnter` that immediately replaces the scene rarely matches intent, and
a dropped promise can hide errors). Production builds suppress the warning.

### Easing

An `EasingFunction` is `(t: number) => number`. Every built-in takes `t` in
`[0,1]` and returns `0` at `t = 0` and `1` at `t = 1`; the result for `t`
outside `[0,1]` is not specified. `easeLinear` plus ten families:

| Family  | Ease in         | Ease out         | Ease in-out        |
| ------- | --------------- | ---------------- | ------------------ |
| sine    | `easeInSine`    | `easeOutSine`    | `easeInOutSine`    |
| quad    | `easeInQuad`    | `easeOutQuad`    | `easeInOutQuad`    |
| cubic   | `easeInCubic`   | `easeOutCubic`   | `easeInOutCubic`   |
| quart   | `easeInQuart`   | `easeOutQuart`   | `easeInOutQuart`   |
| quint   | `easeInQuint`   | `easeOutQuint`   | `easeInOutQuint`   |
| expo    | `easeInExpo`    | `easeOutExpo`    | `easeInOutExpo`    |
| circ    | `easeInCirc`    | `easeOutCirc`    | `easeInOutCirc`    |
| back    | `easeInBack`    | `easeOutBack`    | `easeInOutBack`    |
| elastic | `easeInElastic` | `easeOutElastic` | `easeInOutElastic` |
| bounce  | `easeInBounce`  | `easeOutBounce`  | `easeInOutBounce`  |

`easeInBack`, `easeOutBack`, `easeInOutBack`, `easeInElastic`,
`easeOutElastic` and `easeInOutElastic` leave `[0,1]` between the endpoints —
back reaches ±0.1 past an endpoint, `easeInElastic` and `easeOutElastic`
±0.37, `easeInOutElastic` ±0.12. The interpolated value goes with them, so
pair an overshooting easing with a target that tolerates the excursion:
`camera.zoomTo(0.1, 1, easeOutElastic)` drives `zoom` briefly negative and
flips the view for those frames. `easeInBounce`, `easeOutBounce`
and `easeInOutBounce` stay inside `[0,1]` but are not monotonic.

### Traits

| Export                                    | Purpose                                                                       |
| ----------------------------------------- | ----------------------------------------------------------------------------- |
| `defineTrait<T>(name)`                    | Define a trait token                                                          |
| `@trait(token)`                           | Decorator: declare entity implements trait                                    |
| `TraitToken<T>`                           | Token used with `entity.hasTrait(token)`                                      |
| `entityClassHasTrait(EntityClass, token)` | Check whether an entity class declares or inherits a trait before spawning it |

### Entity Queries

| Export                              | Purpose                                                                                |
| ----------------------------------- | -------------------------------------------------------------------------------------- |
| `QueryCache`                        | Incremental entity query cache                                                         |
| `QueryResult`                       | Iterable result from `cache.register([Component, ...])`                                |
| `cache.queryOnce([Component, ...])` | Detached, seeded, one-shot `QueryResult` — never registered, never updated             |
| `filterEntities(entities, filter)`  | One-off filter by name, tag, component, or trait; skips destroyed and dormant entities |

`cache.register(filter)` returns a `QueryResult` pre-populated with entities that already match, then kept current via `onComponentAdded`/`onComponentRemoved`/`onEntityDestroyed`/`onEntityActivated`/`onEntityDeactivated`. Only active entities are ever members — see Activeness above. Call `cache.unregister(result)` when it no longer needs live updates — otherwise it keeps receiving updates forever. Queries registered once at system-install time (`DisplaySystem`, `UILayoutSystem`) are engine-lifetime by design and are never unregistered; per-mount registrations (e.g. `@yagejs/ui-react`'s `useQuery`) release on unmount.

A filter class matches the class itself and any subclass of it, so `register([Transform, VisualComponent])` finds an entity carrying a `SpriteComponent`. A `QueryResult` holds entities, not components — read them with `entity.getAll(VisualComponent)` when one entity may carry several.

`cache.queryOnce(filter)` builds the same seeded snapshot but skips registration entirely — use it for a point-in-time read (e.g. a render-phase snapshot) that must not hold a live entry in the cache.

`scene.findEntities({ trait: token })` narrows its result: for a `TraitToken<T>` the call returns `(Entity & T)[]`, so the trait's members read without a cast. The `trait` field is the only one that narrows — `filter` is `(entity: Entity) => boolean`, a predicate that selects entities and says nothing about their type, and `filterEntities` returns `Entity[]` for every filter.

### Stable Identity

Opt-in per-scene entity keys. Most entities (bullets, particles, transient enemies) don't need them; pass `{ key }` only for entities whose state should persist (chests, doors, named NPCs).

| Export                    | Purpose                                                                                    |
| ------------------------- | ------------------------------------------------------------------------------------------ |
| `SpawnOptions`            | `{ key?: string; active?: boolean }` — trailing arg of `scene.spawn` / `entity.spawnChild` |
| `entity.key`              | `string \| undefined` — the assigned key                                                   |
| `entity.requireKey()`     | Returns `key` or throws (use in the entity's `setup()` or a component's `onAdd()`)         |
| `scene.findByKey<E>(key)` | Look up entity by key, scene-scoped, hides destroyed entities                              |

```ts yage-context="scene"
import { Entity } from "@yagejs/core";

class Chest extends Entity {
  content: string[] = [];
  setup({ content }: { content: string[] }) {
    this.content = content;
  }
}
class Plain extends Entity {}
class Bone extends Entity {}
const parent = scene.spawn("parent");

scene.spawn(Chest, { content: ["potion"] }, { key: "forest/chest-01" });
scene.spawn(Plain, { key: "spawn-point" }); // class with no setup-params
scene.spawn("anchor", { key: "anchor-01" }); // anonymous entity with a key
parent.spawnChild("body", Bone, { key: "bone-01" });

const chest = scene.findByKey<Chest>("forest/chest-01");
```

The class form derives its trailing args from the entity's `setup` PARAMETER. No declared `setup`, or a zero-parameter `setup(): void` → `spawn(Class, options?)` (no params slot). `setup(params)` with a required parameter → params is required: `spawn(Class, params, options?)`, and `spawn(Class)` is a type error (even when every field of `params` is optional — a required parameter still means `setup(undefined)` would crash). `setup(params?)` or a defaulted parameter → params optional: `spawn(Class, params?, options?)`. Omitting a required field reports that field as missing on the params object (`Property 'spawnPoint' is missing`), naming the field that's actually absent.

The params slot takes the setup param type, not `SpawnOptions`, so a `SpawnOptions`-shaped literal (e.g. `{ key }`) is rejected there; assign a key to an all-optional-param class via the 3-arg form `spawn(Class, {}, { key })`. Edge case: if the setup param type itself declares an optional `key`, `{ key }` satisfies the params slot and the runtime routes it to options — don't name a top-level setup-params field `key`; if you must, use the 3-arg form. The 3-arg form `spawn(Class, params, options)` is always unambiguous.

A throwing `setup()` is terminal. The error reaches the `scene.spawn()` caller unchanged, and the entity stays in the scene holding the components it managed to add, its stable key, and any children its setup already spawned. It is left there to be inspected. Use `scene.spawnBatch` when a half-built entity would be worse than none — a batch discards everything it reserved.

Duplicate keys throw at spawn time with no orphan side-effect — the entity is not added to `scene.entities` and `entity:created` is not emitted. Keys are immutable for an entity's lifetime; destroy + respawn to swap. The index is per-scene and clears on scene teardown. Identity is independent of `@yagejs/save` — game code uses `entity.key` as a stable id in persistent stores (`createSet<string>()`).

### Spawn batches

`scene.spawnBatch(build)` creates a set of entities that all exist before any of them is set up, and that arrive in the scene together or not at all. Use it when entities must reference each other, or when a half-built group would be worse than none.

```ts yage-context="scene"
import { Entity, type EntityHandle } from "@yagejs/core";

class Dummy extends Entity {}
class Turret extends Entity {
  aimAt?: EntityHandle<Dummy>;
  setup({ aimAt }: { aimAt: EntityHandle<Dummy> }) {
    this.aimAt = aimAt;
  }
}

const { turret, target } = scene.spawnBatch((batch) => {
  const turret = batch.reserve(Turret, { key: "level/turret" });
  const target = batch.reserve(Dummy, { key: "level/dummy" });
  batch.addChild(turret, "mount", target); // links before setup runs
  batch.setup(turret, { aimAt: target.handle() });
  batch.setup(target);
  return { turret, target }; // spawnBatch returns this
});
```

| Operation                             | Purpose                                                                    |
| ------------------------------------- | -------------------------------------------------------------------------- |
| `batch.reserve(Class, options?)`      | Construct and key an entity without running `setup()`. Returns the entity. |
| `batch.addChild(parent, name, child)` | Link two reserved entities, so `setup()` can read `this.parent`.           |
| `batch.setup(entity, params?)`        | Run one reserved entity's `setup()`; trailing args follow its signature.   |

- A reserved entity belongs to the scene — `entity.scene`, `entity.handle()`, `entity.requireKey()` all work — but stays out of `scene.getEntities()`, `findByKey()`, and every query until the batch commits. Nothing running in the scene can observe a half-built set.
- On commit, every entity and key is registered first, then `entity:created` and `component:added` publish in reservation order. So the first subscriber already sees the complete set. Activation runs last, parent-first.
- `{ active: false }` on a reservation commits that entity dormant, and everything under it stays dormant too. That is how a level loads without waking up.
- Any throw — from `setup()`, from a lifecycle-event subscriber, from an `onEnable()` hook — discards the whole batch synchronously: components are torn down, keys are released, and no entity is left in the scene. The throw reaches the caller unchanged; teardown failures after it are reported through `Inspector.getErrors().callbackErrors` and never replace it. Rollback is synchronous rather than end-of-frame, so the same keys can be reused in the same scene immediately.
- Inside the callback, `entity.spawnChild(...)` joins the batch and rolls back with it, in every form (anonymous, class, blueprint, keyed). A top-level `scene.spawn()` throws instead, because a batch cannot roll back an entity it does not own. `EntityPool.acquire()` is the same rule seen twice: waking a member the pool already has is an ordinary side effect and works, while one that has to grow the pool spawns a new member and throws. Prewarm the pool outside the batch.
- The callback is the transaction. `entity:created`, `component:added`, and `onEnable()` all run after the entities are in the scene, so a subscriber or hook that spawns is doing ordinary work.
- Developer-emitted events and other side effects a `setup()` performs are outside the transaction. Reserve irreversible work for `onEnable()`.
- The batch is only usable inside the callback. Its operations throw afterwards.

### Assets

| Export           | Purpose                                          |
| ---------------- | ------------------------------------------------ |
| `AssetHandle<T>` | Typed handle returned by asset factory functions |
| `AssetManager`   | Load/unload assets, register loaders             |

### Testing

| Export                                | Purpose                                                                             |
| ------------------------------------- | ----------------------------------------------------------------------------------- |
| `createTestEngine(config?, plugins?)` | Started Engine for integration tests; `plugins` install before it starts            |
| `createMockScene(name?)`              | Lightweight scene with EngineContext for unit tests                                 |
| `createMockEntity(name?)`             | Entity spawned in a mock scene                                                      |
| `advanceFrames(engine, n, dtMs?)`     | Advance game loop by N frames (`dtMs` is the per-frame ms delta; default `1000/60`) |

See also the `Testing & Debugging` section in the Quick Start for a runnable example and the Inspector API for runtime introspection.

### Inspector time and snapshots

The engine creates no Inspector. `DebugPlugin` installs one, and
`InspectorPlugin` installs one without the debug overlay. Reach it with
`engine.context.resolve(InspectorKey)`; `debug: true` also publishes it as
`window.__yage__.inspector`. Time mutation requires `DebugPlugin`.

```ts
import { InspectorPlugin as BaseInspectorPlugin } from "@yagejs/core";
import type { EngineContext } from "@yagejs/core";

declare class InspectorPlugin extends BaseInspectorPlugin {
  readonly name: "inspector";
}
declare function installInspector(context: EngineContext): () => void; // returns the remover
```

`installInspector` reuses an Inspector that is already installed and then
returns a remover that does nothing, so `DebugPlugin` and `InspectorPlugin`
together give one Inspector, removed by whichever created it. A tool that
needs the Inspector before `start()` calls `installInspector(engine.context)`
right after constructing the engine. Plugins that register facet contributors
do it in `onStart`, when every plugin's `install` has run. With `debug: true`
and no Inspector installed, `start()` warns once in dev builds.

```ts
import type {
  InspectorDriveUntilOptions,
  InspectorStepOptions,
  InspectorTime as BaseInspectorTime,
  InspectorTimeControl as BaseInspectorTimeControl,
  InspectorTimeLease as BaseInspectorTimeLease,
} from "@yagejs/core";

interface InspectorTimeControl extends BaseInspectorTimeControl {
  freeze(): void;
  thaw(): void;
  step(frames?: number): void;
  setDelta(ms: number): void;
  isFrozen(): boolean;
  getFrame(): number;
  isAdvancing(withinMs?: number): boolean;
  stepAsync(frames?: number, opts?: InspectorStepOptions): Promise<void>;
  stepUntil(
    predicate: () => boolean,
    opts?: InspectorDriveUntilOptions,
  ): Promise<number>;
}
interface InspectorTimeLease
  extends InspectorTimeControl, BaseInspectorTimeLease {
  release(): void;
}
interface InspectorTime extends InspectorTimeControl, BaseInspectorTime {
  acquire(): InspectorTimeLease;
  isOwned(): boolean;
}
```

`inspector.time: InspectorTime` is the public clock-control surface. `step`
defaults to one frame at the configured delta; `setDelta` sets milliseconds
per frame. Stepping requires a frozen clock. `stepAsync` and `stepUntil` yield
between frames; their `dtMs` override applies to that call only. `stepUntil`
checks before stepping and after each frame, returns the frames used, and
rejects after `maxFrames` (default 600) when still unmatched.

`InspectorStepOptions` accepts `dtMs` and `render: "all" | "last" | "none"`.
Use `"last"` to draw once after a successful batch or `"none"` to skip drawing.
Simulation, layout and pointer hit testing still update. Omitting `render`
keeps the renderer's current drawing setting. `inspector.drive(fn, { render })`
applies the same policy across the whole drive; a step's explicit policy
overrides it for that call. Captures draw current state without advancing time.
See [play sessions](../play-sessions.md#stepping-without-drawing-every-frame).

`acquire()` requires an attached controller and throws if already owned. Use
the returned lease for every mutation until `release()`. Raw time mutators
reject while leased. Queries remain available, including on a released lease.
Release is idempotent; acquisition and release do not freeze, thaw, change
delta or issue frames. Raw async operations hold a lease for their whole
await. Await operations sequentially even through the same lease.
`inspector.drive()` owns a lease, restores the previous frozen state, and
releases ownership after cleanup; advance through its context.

`getFrame()` always reads `engine.loop.frameCount`. Automatic and manual ticks
share the frame identity used by `snapshot().frame`, event entries and logger
entries. Compare against a captured baseline when asserting frames advanced.

`events.waitFor(pattern, { withinFrames?, source? })` checks the earliest
retained match first without consuming it. Clear the log before an action
when the assertion needs a new occurrence. `withinFrames` is validated before
history lookup and must be a non-negative integer. Zero permits history only.
A positive deadline counts real frames from registration and rejects on
completion of the deadline frame if still unmatched; an event during that
frame wins. Frozen clocks need caller-issued frames; no wall-clock timeout
applies. Disposing the Inspector or disabling logging rejects pending waits.
RegExp matching preserves flags and the caller's `lastIndex`.

Snapshot readings:

| Type / field                                 | Meaning                                         |
| -------------------------------------------- | ----------------------------------------------- |
| `WorldEntitySnapshot.name`, `key?`           | Entity name and optional authored key           |
| `WorldEntitySnapshot.generation`, `pooled`   | Entity-life generation and pool membership      |
| `EngineSnapshot.fixedStepIndex`              | Scheduler's count of fixed steps started        |
| `EngineSnapshot.interpolationAlpha`          | Game loop's fraction toward the next fixed step |
| `WorldSceneSnapshot.elapsed`, `fixedElapsed` | SceneTime frame-clock and fixed-clock seconds   |
| `PhysicsSnapshot.elapsed`                    | Physics-world seconds, or `0` without physics   |

All entity counts exclude destroyed entities and include dormant/inactive
ones. The per-entity query helpers (`getEntity`, `getEntityPosition`,
`hasComponent`, `getComponentData`) take a name or an entity id: a name
resolves the first active entity of the active scene; an id (a number from
`getEntities()`, or the string form from `snapshot()` and the event log)
resolves one entity anywhere on the scene stack, dormant and inactive
included, destroyed excluded. A string is matched as a name first. Scene ids
remain unique for the Inspector lifetime, so rebuilt scene instances get
different ids; do not use them as cross-run golden keys. Compare elapsed timestamps
only with readings from the same clock. Inspector snapshots are diagnostics,
not save data. See `debug.md` for event and drive examples.

### Logging & Diagnostics

Category-tagged logger with a ring buffer. Installed on `Engine` and available via `LoggerKey`. The game loop auto-updates the logger's frame counter, so every `LogEntry` carries the frame number it was emitted on.

| Export         | Purpose                                                                                                                            |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `Logger`       | `debug`, `info`, `warn`, `error` (all take `category, message, data?`); `getRecent(count?)`, `formatRecentLogs(count?)`, `clear()` |
| `LogLevel`     | `Debug` (0) / `Info` (1) / `Warn` (2) / `Error` (3) / `None` (4)                                                                   |
| `LoggerConfig` | `{ level?, categories?, bufferSize?, output? }`                                                                                    |
| `LogEntry`     | `{ level, category, message, data?, timestamp, frame }`                                                                            |
| `LoggerKey`    | DI key for resolving a `Logger` from `EngineContext`                                                                               |

```ts
import { Engine, LogLevel } from "@yagejs/core";

const engine = new Engine({ debug: true });

engine.logger.info("physics", "Shape spawned", { x: 100, y: 200 });
engine.logger.warn("gameplay", "Low health");
engine.logger.error("render", "Texture missing", { key: "hero.png" });

// Dump the most recent entries (e.g. on crash)
console.log(engine.logger.formatRecentLogs(20));
```

`bufferSize` (default 500) caps the ring buffer. `categories` restricts which categories are accepted. `output` overrides the default `console.*` handler with a custom sink (e.g., to ship logs to a remote service).

### Well-known DI Keys

`EngineKey`, `EventBusKey`, `SceneManagerKey`, `LoggerKey`, `QueryCacheKey`, `ErrorBoundaryKey`, `GameLoopKey`, `InspectorKey` (only once `DebugPlugin` or `InspectorPlugin` installs an Inspector), `SceneRandomSourceKey`, `SystemSchedulerKey`, `ProcessSystemKey`, `AssetManagerKey`

## LoadingScene

Base class for a progress-bar loading screen. Orchestrates preload, emits events on the bus, and hands off to a target scene. No rendering — the visual lives in `@yagejs/ui` (`LoadingSceneProgressBar`) or user-written components subscribing to the events. Full reference: `loading-scene.md`.

```ts
import { LoadingScene, Scene } from "@yagejs/core";
import { fade } from "@yagejs/renderer";
import { LoadingSceneProgressBar } from "@yagejs/ui";

class GameScene extends Scene {
  readonly name = "game";
}

class Boot extends LoadingScene {
  readonly target = new GameScene();
  readonly minDuration = 0.5;
  readonly transition = fade({ duration: 0.3 });
  override onEnter() {
    this.spawn(LoadingSceneProgressBar);
    this.startLoading();
  }
}
```

Emits `scene:loading:progress` and `scene:loading:done` on `EventBusKey`. Set `autoContinue = false` and call `scene.continue()` to gate the handoff (e.g. "press any key").

## State

Typed reactive primitives for game-wide singleton state. Used by `@yagejs/ui-react`'s `useStore` and the save layer.

### Contracts

Three independent interfaces; every `Reactive*` shape implements all three:

```ts
import type {
  Reactive as BaseReactive,
  Resettable as BaseResettable,
  Serializable as BaseSerializable,
} from "@yagejs/core";

interface Reactive extends BaseReactive {
  subscribe(fn: () => void): () => void;
}
interface Serializable<TEnc> extends BaseSerializable<TEnc> {
  serialize(): TEnc;
  hydrate(raw: TEnc): void;
}
interface Resettable extends BaseResettable {
  reset(): void;
}
```

Each shape also carries a `[STATE_KIND]` symbol-brand (`"value" | "counter" | "record" | "map" | "set" | "list" | "store"`) — `useStore` dispatches on it.

```ts
import type {
  DeletableRecordKey,
  EncodedStore,
  ListEncoded,
  Reactive,
  ReactiveCounter as BaseReactiveCounter,
  ReactiveList as BaseReactiveList,
  ReactiveMap as BaseReactiveMap,
  ReactiveRecord as BaseReactiveRecord,
  ReactiveSet as BaseReactiveSet,
  ReactiveValue as BaseReactiveValue,
  Resettable,
  Serializable,
  StoreLeaves,
} from "@yagejs/core";

interface ReactiveValue<T>
  extends
    BaseReactiveValue<T>,
    Reactive,
    Serializable<{ value: T }>,
    Resettable {
  get(): T;
  set(v: T): void;
}
interface ReactiveCounter
  extends BaseReactiveCounter, Reactive, Serializable<number>, Resettable {
  value(): number;
  set(n: number): void;
  increment(by?: number): void;
  decrement(by?: number): void;
  clamp(value: number, min: number, max: number): void;
}
interface ReactiveRecord<T extends object>
  extends BaseReactiveRecord<T>, Reactive, Serializable<T>, Resettable {
  get(): Readonly<T>;
  set(partial: Partial<T>): void;
  // Removes the key entirely (`set` can only overwrite). Absent key = no-op, no
  // notify. Accepts index-signature keys (`Record<string, V>`) and optional keys;
  // on a fixed-shape record a required key is a compile error, since `get()` is
  // typed `Readonly<T>`. (A type mixing an index signature with declared keys is
  // an open bag — every string key is deletable.) Declared as a property, not a
  // method, so it is contravariant: a fixed-shape record is not assignable to an
  // open-ended `ReactiveRecord<Record<string, V>>`.
  delete: (key: DeletableRecordKey<T>) => void;
}
interface ReactiveMap<K, V>
  extends
    BaseReactiveMap<K, V>,
    Reactive,
    Serializable<Array<[K, V]>>,
    Resettable {
  get(k: K): V | undefined;
  set(k: K, v: V): void;
  delete(k: K): void;
  has(k: K): boolean;
  entries(): Array<[K, V]>;
  size(): number;
  clear(): void;
}
interface ReactiveSet<K>
  extends BaseReactiveSet<K>, Reactive, Serializable<K[]>, Resettable {
  add(k: K): void;
  delete(k: K): void;
  has(k: K): boolean;
  values(): K[];
  size(): number;
  clear(): void;
}
interface ReactiveList<T>
  extends
    BaseReactiveList<T>,
    Reactive,
    Serializable<ListEncoded<T>>,
    Resettable {
  add(item: T): number; // returns assigned id
  remove(id: number): boolean; // by id, not delete — semantically distinct
  get(id: number): T | undefined;
  update(id: number, partial: Partial<T>): boolean;
  list(): T[];
  size(): number;
  clear(): void;
  // keyed lookup — requires the `keyBy` option, else these throw.
  // A keyed list holds at most one item per key; add/update/upsert throw on a
  // duplicate key. upsert requires keyBy(item) === key.
  findId(key: string | number): number | undefined; // id for a domain key
  getByKey(key: string | number): T | undefined; // item for a domain key
  upsert(key: string | number, item: T): number; // add-or-replace by key; returns id
}
interface ReactiveStore<L extends StoreLeaves>
  extends Reactive, Serializable<EncodedStore<L>>, Resettable {
  /* plus L's leaves */
}
```

### Factories

```ts
import {
  createValue,
  createCounter,
  createRecord,
  createMap,
  createSet,
  createList,
  createStore,
} from "@yagejs/core";

interface Settings {
  music: number;
  sfx: number;
}
interface Potion {
  name: string;
  quality: number;
}

// Leaf factories — usable on their own.
const settings = createRecord<Settings>({
  default: () => ({ music: 0.8, sfx: 1.0 }),
});
const opened = createSet<string>();
const enemies = createMap<string, number>();
const restEpoch = createCounter();
const day = createValue<number>({ default: 1 });
const journal = createList<{ at: number; text: string }>();

// Keyed list — pass `keyBy` to look items up by a domain field in O(1).
// Keys are unique: at most one item per key. add/update/upsert throw if the
// result would share a key with another item; upsert requires keyBy(item) === key.
const inventory = createList<{ itemId: string; quantity: number }>({
  keyBy: (slot) => slot.itemId,
});
inventory.upsert("sword", { itemId: "sword", quantity: 1 }); // insert
inventory.upsert("sword", { itemId: "sword", quantity: 2 }); // replace in place
inventory.findId("sword"); // -> id
inventory.getByKey("sword"); // -> { itemId: "sword", quantity: 2 }

// Compound — bundle leaves so they serialise/restore atomically.
const game = createStore((s) => ({
  inventory: s.map<string, number>(),
  recipes: s.set<string>(),
  gold: s.counter({ default: 0 }),
  shelf: s.list<Potion>(),
  day: s.value<number>({ default: 1 }),
  settings: s.record<Settings>({
    default: () => ({ music: 0.8, sfx: 1.0 }),
  }),
}));
game.gold.increment(10);
game.inventory.set("moonleaf", 3);
```

Factories take no id and no version — they return fresh, pure data instances. Ids and version envelopes live at the save call site (`@yagejs/save`). `useStore(compound)` works and returns the encoded snapshot, though reading individual leaves keeps subscription granularity per-leaf.

Codecs for non-JSON-native types: `jsonCodec()`, `setCodec<K>()`, `mapCodec<K,V>()`, `dateCodec()`. Set/Map/Counter/List bundle codecs internally; you only specify a codec on `createRecord<T>` / `createValue<T>` (or the matching `s.record`/`s.value` leaves) for exotic types.

See `@yagejs/save` docs for the IO layer that consumes any `Serializable<T>`.

## Core Types

```ts
import type {
  Component,
  EngineContext,
  Plugin as BasePlugin,
  SystemScheduler,
} from "@yagejs/core";

interface Plugin extends BasePlugin {
  readonly name: string;
  readonly version: string;
  readonly dependencies?: readonly string[];
  install?(context: EngineContext): void | Promise<void>;
  registerSystems?(scheduler: SystemScheduler): void;
  onStart?(): void | Promise<void>;
  onDestroy?(): void;
}

declare enum Phase {
  EarlyUpdate = "earlyUpdate",
  FixedUpdate = "fixedUpdate",
  Update = "update",
  LateUpdate = "lateUpdate",
  Render = "render",
  EndOfFrame = "endOfFrame",
}

type EasingFunction = (t: number) => number;
type ComponentClass<C extends Component = Component> = abstract new (
  ...args: never[]
) => C;
```

### Execution context

The scheduler (resolve via `SystemSchedulerKey`) reports where the current
call is executing. Code reachable from several phases branches on these
instead of assuming a phase — `@yagejs/input` uses them to scope edge queries
to the caller's frame or fixed step:

```ts yage-context="context"
import { SystemSchedulerKey } from "@yagejs/core";

const scheduler = context.resolve(SystemSchedulerKey);

scheduler.currentPhase; // Phase | null — phase running right now; null outside any phase
scheduler.fixedStepIndex; // number — monotonic count of fixed steps started; identifies
// the running step during Phase.FixedUpdate (a frame can run
// several steps, or none), holds the last step's number between steps
```

### Frame order

Every shipped system, in the order one frame runs them. Engine steps are in
italics; systems read `Name (priority, package)`. Equal priorities run in add
order, which for plugin systems is plugin install order (`UILayoutSystem`
before `UIRootLayoutSystem` because `ui-react` depends on `ui`).

- `EarlyUpdate`: _`logger.setFrame`, `SceneTime` frame tick per active scene, transition tick_ → `InputPollSystem (-100, input)`
- `FixedUpdate`, 0 to `maxFixedStepsPerFrame` times: _`SceneTime` fixed tick per active scene_ → `PhysicsSystem (0, physics)` → `ProcessFixedUpdateSystem (500, core)` → `ComponentFixedUpdateSystem (1000, core)`
- `Update`: `PhysicsInterpolationSystem (-100, physics)` → `ProcessSystem (500, core)` → `ComponentUpdateSystem (1000, core)`
- `LateUpdate`: `ParticleSystem (0, particles)` → `UILayoutSystem (200, ui)` → `UIRootLayoutSystem (200, ui-react)` → `FloatingOverlaySystem (201, ui)` → `UIFocusSystem (202, ui)` → `UIFocusRelayoutSystem (203, ui)`
- `Render`: `DisplaySystem (0, renderer)` → `LightingSystem (100, lighting)` → `DebugRenderSystem (9999, debug)`
- `EndOfFrame`: `InputClearSystem (9000, input)` → _destroy-queue flush_ → _frame-end observers: an installed Inspector expires `waitFor` deadlines here, so events from the flush still count on their deadline frame_

The level editor's preview scene adds three systems of its own to that
order, none of which a game sees:

- `EarlyUpdate`: `DestroyFlushSystem (0, editor)`, which runs asset releases after the flush that took their entities out
- `Render`: `DormantVisualSystem (100, editor)`, which draws the dormant placements after the renderer's pass, then `OverlaySystem (110, editor)`, which redraws the editor's grid, gizmo, and handles

Priority bands, for placing a new system:

| Band       | Runs                                                                             | Shipped systems                   |
| ---------- | -------------------------------------------------------------------------------- | --------------------------------- |
| below 0    | before the engine's producers: reads external input or the previous step's state | input poll, physics interpolation |
| 0          | producers                                                                        | physics step, particles, display  |
| 100–201    | consumers of the producers                                                       | lighting, UI layout               |
| 500        | timing                                                                           | both process systems              |
| 1000       | game code                                                                        | both component passes             |
| above 1000 | after game code                                                                  | input clear, debug overlay        |

Ordering rules:

- Runtime `scheduler.add(system)`: added while its own phase is running, the system first runs at that phase's next run; added during an earlier phase of the same frame, it runs its phase this frame; added during a later phase, next frame. A system removed while its phase is running does not run again. `getSystems(phase)` returns the current list; a list captured earlier is a stale snapshot.
- Live entity set: `ComponentUpdateSystem`, the process systems and the physics systems iterate `scene.getEntities()` live, in insertion order. An entity spawned or pool-acquired during a pass is visited by that pass, after the entity that created it. An entity re-activated with `setActive(true)` during a pass keeps its position and is visited by that pass only if it sits after the activator.
- One pass later: anything a component's `update`/`fixedUpdate` schedules on a `ProcessComponent` — `pc.run`, a slot `start`, a tween — first advances on the next pass of its clock, because the process systems at 500 have already run when component code at 1000 schedules it. A system below 500 that schedules a process sees it advance in the same pass.
- `loop.stop()`, and `engine.destroy()` which calls it, from inside a phase takes effect at that phase's boundary: the running phase's systems finish, and the phases after it are skipped, remaining fixed steps included.
- `loop.tick(dtMs)` throws unless `dtMs` is a finite number >= 0 (`GameLoop.tick: dtMs must be a finite number >= 0, got ${x}.`); `0` is a frame with no fixed step.

## Error Handling

`ErrorBoundary` wraps system, component, and callback execution so a throw is
attributed to whoever threw, not whoever it reached. It never disables,
unsubscribes, mutes, or cancels anything — it records the culprit, logs it,
and rethrows.

- `wrapSystem(system, fn)` / `wrapComponent(component, fn)` — used internally
  by `SystemScheduler` and `ComponentUpdateSystem`. On throw, records the
  system/component's identity (and owning entity for a component), logs it
  through `Logger`, and rethrows. `update`/`fixedUpdate` are typed
  void-returning but an `async` one compiles against that signature, so a
  rejected thenable is reported the same way — re-raised as a new unhandled
  rejection, since the original call stack has already returned.
- `wrapCallback(fn, info)` wraps a developer-supplied callback the engine
  invokes on its own — collision/trigger handlers, entity/scene event
  handlers, the global `EventBus`, input listeners (key/action/gamepad/
  pointer/wheel), process and process-slot callbacks, the audio unlock
  callback. It catches a
  synchronous throw and, since these callbacks are typed void-returning but
  nothing stops a caller from passing an `async` function, a rejected
  thenable too — the thenable case is re-raised as a new unhandled rejection,
  since a rejected `.then()` handler can't rethrow into the original
  (already-returned) call stack.
- `wrapLifecycleHook(fn, info)` wraps a scene lifecycle hook
  (`onEnter`/`onExit`/`onPause`/`onResume`/`beforeEnter`). A synchronous
  throw is reported through `Logger` and rethrown, so a scene half-built by a
  throwing hook always fails the same way — it must not look like it mounted
  cleanly. A rejected thenable can only be reported (via
  `reportLifecycleError()`), not rethrown — the hook call has already
  returned by the time the rejection settles, so there's no caller stack
  left to rethrow into.
- Every failure is recorded in `ErrorBoundary.getCallbackErrors()`, which
  needs no Inspector, and with one installed is also read as
  `Inspector.getErrors().callbackErrors` — a bounded history (the
  200 most recent) with each entry's kind and owning entity/scene/event where
  known. The same `Error` object propagating through nested wraps (a
  collision handler's throw reaching the surrounding `wrapSystem`) is
  recorded and logged once, not once per wrap.
- `GameLoop.tick()` is the one place that decides a failure is terminal: an
  error that escapes an entire frame unhandled stops the loop and rethrows,
  so it reaches the host. A caller's own `try`/`catch` around a dispatching
  call (`entity.emit(...)`, `bus.emit(...)`, ...) leaves the loop running.
- A throw inside an engine-owned sequence — scene teardown, an entity destroy
  cascade, a pool disposal, an event fan-out — stops the remaining steps, and
  whatever they would have released stays allocated. The engine reports the
  failure; it does not finish the operation or undo the part that ran. Two
  places run every step anyway: `Engine.destroy()` (the host is quitting) and
  the plugin `afterExit` hooks, where one plugin's failure is reported and the
  other plugins still tear their scene state down.
- Writing a new dispatch site that calls developer-supplied code should route
  it through `wrapCallback`/`wrapLifecycleHook` rather than calling the
  callback directly — see the "Attribute developer-supplied callbacks" rule
  in the repo-root `AGENTS.md`.

`Logger` writes to the console by default in dev builds (gated by `isDev()`, off in production builds like `devWarn`). Pass `logger: { output }` in the `Engine` config to replace it; `LogLevel.None` silences everything. The `output` sink itself is guarded: a sink that throws, or returns a promise that rejects, is disabled from its first observed failure on, with one console message, instead of taking down whatever was being reported. Calls an async sink already received before its first rejection settles still run.
