Scenes
A scene is a collection of entities, components, and parent-child relationships. You create scenes visually in the Estella editor, and they are saved as JSON files. At runtime, the engine loads the scene and spawns all entities with their configured components.
Scene Structure
A scene file contains:
- Entities — each with a unique ID and a name
- Components — data attached to each entity (transforms, sprites, colliders, etc.)
- Hierarchy — parent-child relationships between entities
{ "version": "1.0", "name": "Level1", "entities": [ { "id": 1, "name": "Player", "parent": null, "children": [2], "components": [ { "type": "Transform", "data": { "position": { "x": 0, "y": 0, "z": 0 } } }, { "type": "Sprite", "data": { "texture": "player.png", "size": { "x": 32, "y": 32 } } } ] } ]}Loading Scenes
In the editor, scenes are loaded automatically — you only write scripts that define components and systems.
After building your project, the engine automatically loads the scene, resolves all referenced assets (textures, materials, Spine), and creates a default camera if none exists. You don’t need to handle scene loading manually.
Finding Entities
By Name
Every entity loaded from a scene receives a built-in Name component with the name assigned in the editor. Query Name to find entities:
import { defineSystem, addStartupSystem, Query, Name, Transform } from 'esengine';
addStartupSystem(defineSystem( [Query(Name, Transform)], (query) => { for (const [entity, name, transform] of query) { if (name.value === 'Player') { // found the player entity } } }));By Component or Tag
Use a Query to find entities by their components:
import { defineSystem, addSystem, defineTag, Query, Mut, Transform } from 'esengine';
const Player = defineTag('Player');
addSystem(defineSystem( [Query(Mut(Transform), Player)], (query) => { for (const [entity, transform] of query) { // process each player } }));Traversing the Hierarchy
Use the Children component to walk the entity tree:
import { defineSystem, addSystem, Query, Children, Transform } from 'esengine';
addSystem(defineSystem( [Query(Children, Transform)], (query) => { for (const [entity, children, transform] of query) { // children.entities contains child entity IDs } }));Entity Hierarchy
Scene entities can form a tree via parent-child relationships. Set up parent-child relationships in the editor — the engine manages Parent and Children components automatically.
When a parent’s Transform changes, the engine automatically propagates the world transform to all descendants.
Dynamic Entities
You can spawn and despawn entities at runtime using Commands:
import { defineSystem, addSystem, Commands, Transform, Sprite } from 'esengine';
addSystem(defineSystem( [Commands()], (commands) => { // Spawn a new entity with components commands.spawn() .insert(Transform, { position: { x: 100, y: 0, z: 0 }, rotation: { w: 1, x: 0, y: 0, z: 0 }, scale: { x: 1, y: 1, z: 1 } }) .insert(Sprite, { texture: bulletTexture, color: { r: 1, g: 1, b: 1, a: 1 }, size: { x: 8, y: 8 } });
// Despawn an entity commands.despawn(entity); }));Entity Visibility
Entities have a visible property (default true). When set to false in the editor, the entity’s components are skipped during scene loading — it exists in the hierarchy but has no runtime behaviour. Use this to keep reference entities in the scene file without spawning them.
Scene Manager
The SDK provides the SceneManager resource and sceneManagerPlugin for managing the loading, switching, and lifecycle of multiple scenes. The plugin is included by the engine automatically — no manual registration needed.
import { Res, SceneManager, defineSystem, addSystem } from 'esengine';
addSystem(defineSystem( [Res(SceneManager)], (mgr) => { // access scene manager in a system }));Registering Scenes
Editor Workflow (Recommended)
Most projects manage scenes entirely through the editor:
- Right-click in the Content Browser → Create → Scene to create a
.esscenefile (default location:assets/scenes/main.esscene) - Open Build Settings and add the scenes you want to include in the build
- Build the project — the build system reads each
.esscenefile, extracts its name, and embeds the scene data directly into the build output
At runtime, the engine automatically registers and loads these embedded scenes. You don’t need to write any scene loading code or specify any file paths.
Programmatic Registration
For multi-scene projects that need scene switching, custom systems, or setup/cleanup callbacks, register scenes in a setup function. You still don’t need path — the build system handles data embedding:
import { SceneManager, Schedule, type App } from 'esengine';
export function setup(app: App) { const mgr = app.getResource(SceneManager);
mgr.register({ name: 'menu', systems: [ { schedule: Schedule.Update, system: menuUpdateSystem }, ], setup: (ctx) => { ctx.registerDrawCallback('menu-particles', drawParticles); }, cleanup: (ctx) => { // called before scene unload }, });
mgr.register({ name: 'game' }); mgr.setInitial('menu');}Inline Scene Data
You can pass scene data directly via data for procedurally generated or in-memory scenes:
mgr.register({ name: 'procedural', data: { version: '1.0', name: 'procedural', entities: [], }, setup: (ctx) => { // generate entities programmatically },});Loading from URL (Advanced)
For advanced use cases — such as loading scenes from a CDN or hot-reloading during development — use the path field to specify a URL:
mgr.register({ name: 'menu', path: '/scenes/menu.json',});Path resolution rules:
| Path format | Resolves to | Example |
|---|---|---|
Starts with / | Absolute path from site root | /scenes/menu.json → /scenes/menu.json |
Starts with http:// or https:// | Full URL, used as-is | https://cdn.example.com/scenes/menu.json |
| Relative path | Prepended with AssetServer.baseUrl | scenes/menu.json → {baseUrl}/scenes/menu.json |
Switching Scenes
Use SceneManager to switch between registered scenes. Switching unloads the current scene and loads the new one:
const mgr = app.getResource(SceneManager);
// Simple switch (unloads old, loads new)await mgr.switchTo('game');
// Switch with fade transitionawait mgr.switchTo('game', { transition: 'fade', duration: 0.5 });
// Full transition optionsawait mgr.switchTo('game', { transition: 'fade', duration: 1.0, color: { r: 0, g: 0, b: 0, a: 1 }, keepPersistent: true, onStart: () => console.log('transition started'), onComplete: () => console.log('transition finished'),});Full switchTo options:
| Option | Type | Default | Description |
|---|---|---|---|
transition | 'none' | 'fade' | 'none' | Transition animation type |
duration | number | engine default | Total transition duration (seconds); fade-out and fade-in each take half |
color | Color | engine default | Mask colour for the fade |
keepPersistent | boolean | true | Whether to keep persistent entities |
onStart | () => void | - | Callback when the transition starts |
onComplete | () => void | - | Callback when the transition finishes |
Multi-Scene (Additive Loading)
Load additional scenes on top of the current primary scene. This is useful for HUDs, pause menus, or overlay layers:
const mgr = app.getResource(SceneManager);
// Load a scene on top of the current oneawait mgr.loadAdditive('hud');await mgr.loadAdditive('pause-menu');
// Unload additive sceneawait mgr.unload('pause-menu');Scene Lifecycle
Control whether a loaded scene’s systems and entities are active:
const mgr = app.getResource(SceneManager);
// Pause — systems stop, entities remain visiblemgr.pause('game');mgr.resume('game');
// Sleep — systems stop, entities hiddenmgr.sleep('game');mgr.wake('game');| Method | Systems | Entities Visible |
|---|---|---|
resume / wake | Running | Yes |
pause | Stopped | Yes |
sleep | Stopped | No |
Scene Context
When a scene loads, its setup callback receives a SceneContext that provides scene-scoped operations:
app.registerScene({ name: 'game', path: '/scenes/game.json', setup: (ctx) => { // Spawn entities owned by this scene const e = ctx.spawn();
// Register draw callbacks scoped to this scene ctx.registerDrawCallback('game-debug', drawDebug);
// Bind post-process effects scoped to this scene const fx = PostProcess.createStack(); fx.addPass('bloom', PostProcess.createBloom()); ctx.bindPostProcess(cameraEntity, fx);
// Mark entity as persistent (survives scene unload) ctx.setPersistent(e, true); },});Entities spawned through SceneContext are automatically tagged with a SceneOwner component and cleaned up when the scene unloads — unless marked as persistent.
SceneOwner Component
Entities loaded from a scene file automatically get a SceneOwner component. Use it to check which scene owns an entity:
import { SceneOwner } from 'esengine';
const owner = world.get(entity, SceneOwner);console.log(owner.scene); // 'game'console.log(owner.persistent); // falseScene-scoped Systems
Systems registered in a scene config only run when that scene’s status is 'running'. They are automatically wrapped so they skip execution when the scene is paused or sleeping:
import { defineSystem, Query, Mut, Transform, Schedule } from 'esengine';
const mySystem = defineSystem( [Query(Mut(Transform))], (query) => { for (const [entity, transform] of query) { // runs only while 'game' scene is running } });
app.registerScene({ name: 'game', path: '/scenes/game.json', systems: [{ schedule: Schedule.Update, system: mySystem }],});Scene Transitions
Use the transitionTo helper for animated scene switches:
import { transitionTo } from 'esengine';
// Fade to black, switch scene, fade inawait transitionTo(app, 'game', { type: 'fade', duration: 1.0 });
// Custom mask colourawait transitionTo(app, 'game', { type: 'fade', duration: 0.5, color: { r: 1, g: 1, b: 1, a: 1 },});transitionTo is a convenience wrapper around mgr.switchTo(). It obtains the SceneManager resource internally and calls switchTo. The process is asynchronous — the old scene fades out, the scene switch executes, then the new scene fades in.
Querying Scene State
SceneManager provides a set of methods for inspecting scene status:
const mgr = app.getResource(SceneManager);
mgr.getActive(); // current primary scene name, or nullmgr.isActive('game'); // whether a given scene is the primary scenemgr.getActiveScenes(); // all scene names with 'running' statusmgr.getLoaded(); // all loaded scene namesmgr.isLoaded('game'); // whether the scene is loadedmgr.isPaused('game'); // whether the scene is pausedmgr.isSleeping('game'); // whether the scene is sleepingmgr.isTransitioning(); // whether a scene transition is in progressmgr.getSceneStatus('game'); // detailed scene statusmgr.getScene('game'); // get the scene's SceneContext, or null if not loadedmgr.getLoadOrder(); // scene names in load ordergetSceneStatus return values:
| Status | Description |
|---|---|
'loading' | Scene is currently loading |
'running' | Scene is running |
'paused' | Scene is paused (systems stopped, entities visible) |
'sleeping' | Scene is sleeping (systems stopped, entities hidden) |
'unloading' | Scene is currently unloading |
null | Scene is not loaded |
Scene Layer Ordering
When multiple scenes are loaded simultaneously, rendering and system execution follow load order. Use bringToTop() to move a scene to the top:
const mgr = app.getResource(SceneManager);
await mgr.loadAdditive('hud');await mgr.loadAdditive('dialog');
mgr.bringToTop('hud');mgr.getLoadOrder(); // ['game', 'dialog', 'hud']Scene Data Types
| Type | Fields |
|---|---|
SceneData | version: string, name: string, entities: SceneEntityData[], textureMetadata?: Record<string, TextureMetadata> |
SceneEntityData | id: number, name: string, parent: number | null, children: number[], components: SceneComponentData[], visible?: boolean |
SceneComponentData | type: string, data: Record<string, unknown> |