Skip to content

Plugins & Resources

An Estella game is an App composed of plugins. Each subsystem — physics, audio, tilemaps, particles, UI, and so on — is a plugin that registers its systems and exposes a runtime API as a resource.

Inside a system, list the resource you need in the parameter array; the callback receives it in the same order:

import { defineSystem, Res, Audio } from 'esengine';
defineSystem([Res(Audio)], (audio) => {
audio.playSFX('assets/hit.wav');
});

Every subsystem’s imperative API is reached the same way:

Subsystem Resource Guide
Input Res(Input) Input
Audio Res(Audio) Audio
Assets Res(Assets) Assets
Tilemaps Res(Tilemaps) Tilemaps
Particles Res(Particle) Particles
Post-processing Res(PostProcess) Post-processing
Scenes Res(SceneManager) Scenes
Tweens Res(Tween) Animation
Frame animation Res(SpriteAnimation) Animation
Animator Res(AnimatorController) Animation
Timers Res(TimerRes) Scripting
Timeline Res(Timeline) Timeline
Prefabs Res(Prefabs) Prefabs
Navigation / AI Res(Nav) Gameplay AI
Localization Res(Localization) Localization
Camera view Res(CameraView) Camera
UI events Res(UIEvents) UI
Physics Res(Physics) — from esengine/physics Physics
Spine Res(Spine) — from esengine/spine Spine

Physics and Spine are optional side-modules loaded on demand, so their APIs live on subpaths to keep the base bundle small; everything else is in the main esengine package. All the base subsystems above ship in the standard app — no plugin wiring needed.

Some subsystems also publish event resources on the same pattern — a resource whose contents are this frame’s events, read the same frame: Res(Physics2DEvents) (contacts), Res(SpineEvents) (animation events), Res(UIEvents) (clicks and input from widgets).


Res(X) hands the system the resource itself. ResMut(X) hands it a handle wrapped around the resource — .get() to reach the value, .set(v) to replace it, .modify(fn) to mutate it in place. Use ResMut when the system replaces or mutates the resource’s state — same contract as Mut on components. The next section shows both shapes side by side.

A resource is just a value the app owns, and it does not have to be plain data. Every subsystem API in the table above is a class instance published as a resource — Res(Audio) is an AudioAPI, Res(SceneManager) is a SceneManagerState with switchTo / load / unload on it. Your own resources can carry methods the same way.

defineResource(defaultValue, name) creates the definition — an identity, not the value itself:

import { defineResource } from 'esengine';
class ScoreState {
value = 0;
combo = 1;
add(points: number): void {
this.value += points * this.combo;
}
}
export const Score = defineResource<ScoreState>(null!, 'Score');

The null! is a deliberate placeholder: the real instance is inserted when the plugin builds, so each app gets its own. This is exactly how every engine subsystem is wired — SceneManager is declared defineResource<SceneManagerState>(null!, 'SceneManager') and its plugin does app.insertResource(SceneManager, new SceneManagerState(app)).

import type { App, Plugin } from 'esengine';
export const scorePlugin: Plugin = {
name: 'score',
build(app: App) {
app.insertResource(Score, new ScoreState());
},
};

From there it is reached like any other resource:

import { defineSystem, Res, ResMut } from 'esengine';
defineSystem([Res(Score)], (score) => {
// score IS the ScoreState instance
if (score.value > 1000) { /* … */ }
});
defineSystem([ResMut(Score)], (score) => {
// score is the handle — unwrap it
score.modify((s) => s.add(100));
});

Outside ECS code — host code, a defineBehavior hook — use app.getResource(Score), which returns that same live instance.

A resource is materialised the first time it is read, by cloning the default you passed to defineResource. That clone is structural, not total:

Default value What each app ends up with
Plain object / array A deep clone — one independent copy per app.
Class instance The same object, shared by every app that reads it.
Function-valued property Shared by reference — methods always survive the clone.

Class instances pass through by reference on purpose: cloning them into a bare object would strip their prototype and with it every method. The practical consequence is that defineResource(new ScoreState(), 'Score') gives one score shared by every app in the process — and the editor runs two (edit mode and play mode). Prefer the null! + insertResource form above whenever the resource holds state.

  • Components are per-entity data. You attach them (in the editor or with Commands) and iterate them with Query. C++-backed components are decoded out of the wasm heap on every read, so what you get is a copy — change it and hand it back, or the write is lost (see Scripting).
  • Resources are per-app singletons — shared state and subsystem APIs — read with Res. A resource is the live object, never a copy: calling a method or setting a field on it takes effect immediately.

Subsystem APIs act on an entity, and that entity comes from a Query (or a spawn). This is the shape you’ll see throughout the guides:

import { defineSystem, Query, Res, SpriteAnimator, Tween, TweenTarget } from 'esengine';
defineSystem([Query(SpriteAnimator), Res(Tween)], (q, tween) => {
for (const [entity] of q) {
tween.to(entity, TweenTarget.PositionX, 0, 100, 1.0, {});
}
});

To create entities and attach components from a system, take Commands():

import { defineSystem, Commands, Transform, Sprite } from 'esengine';
defineSystem([Commands()], (cmds) => {
cmds.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(Sprite, { size: { x: 32, y: 32 } });
});