Scenes
A project is organized into scenes. They all share one ECS world; the
SceneManager resource loads, switches, and unloads them, and gates each scene’s
systems to run only while that scene is active.
Switch scenes
Section titled “Switch scenes”switchTo replaces the active scene (with an optional transition). It’s async —
await it:
import { defineSystem, Res, SceneManager } from 'esengine';
const goToLevel2 = defineSystem([Res(SceneManager)], async (scenes) => { await scenes.switchTo('level2');});Scene transitions
Section titled “Scene transitions”switchTo and unload take TransitionOptions. With transition: 'fade' the
switch fades through a solid color in three phases: the overlay ramps to opaque
over the first half of duration, holds opaque while the old scene unloads and
the new one loads (a slow load extends the hold instead of flashing), then
ramps back over the second half. The awaited promise resolves only after the
fade-in completes — and rejects if the load failed. A switchTo issued while a
transition is in flight is ignored with a warning; isTransitioning() tells you.
If a load is superseded before it commits — an unload, or another load of the
same scene, races ahead of it — the pending promise rejects with a
SceneLoadCancelled error rather than committing a half-loaded scene. It is a
race signal, not a real failure, so branch on it in your catch (e.g. ignore it)
instead of surfacing it as a load error.
| Option | Type | Default | Description |
|---|---|---|---|
transition |
'none' | 'fade' |
'none' |
Hard cut, or fade through color. |
duration |
number | 0.3 |
Total fade time in seconds — half out, half in (RuntimeConfig.sceneTransitionDuration). |
color |
Color |
opaque black | Overlay color (RuntimeConfig.sceneTransitionColor; the build config takes a hex string). |
onStart / onComplete |
callback | – | Fired when the fade starts / after the fade-in finishes. |
keepPersistent |
boolean | true |
Keep entities marked via SceneContext.setPersistent alive through the unload. |
transitionTo is a one-call convenience over the same machinery, configured
with a TransitionConfig (type: 'fade', duration, optional color). It
accepts the App (bootstrap code, menu glue) or the SceneManager resource
state — inside a system, pass what Res(SceneManager) gives you. A menu →
level flow:
import { transitionTo, type TransitionConfig } from 'esengine';
const fadeToBlack: TransitionConfig = { type: 'fade', duration: 0.5, color: { r: 0, g: 0, b: 0, a: 1 },};
async function onPlayClicked() { await transitionTo(app, 'levels/level1', fadeToBlack); // menu → level}Additive scenes
Section titled “Additive scenes”Load a scene on top of the current one — a HUD, a pause menu, a streamed region
— without unloading what’s underneath. loadAdditive takes an optional progress
callback and returns the scene context:
await scenes.loadAdditive('pause-menu', (loaded, total) => { /* progress */ });// …laterawait scenes.unload('pause-menu');Scene streaming (open worlds)
Section titled “Scene streaming (open worlds)”For worlds too big to load at once, the SceneStreaming resource loads and
unloads cells — additive scenes placed in world space — around a focus point:
import { defineSystem, Res, SceneStreaming } from 'esengine';
const setupStreaming = defineSystem([Res(SceneStreaming)], (streaming) => { streaming.configure({ loadRadius: 1200, unloadRadius: 1600 }); streaming.register({ scene: 'cells/forest', x: 0, y: 0, radius: 800 }); streaming.register({ scene: 'cells/village', x: 1500, y: 0, radius: 800 }); streaming.setFocusEntity(player); // or setFocus(x, y) each frame});Each frame, cells within loadRadius of the focus load additively and cells
beyond unloadRadius unload — the gap between the radii is hysteresis, so a
player standing at a border doesn’t thrash loads. policy: 'sleep' sleeps
cells instead of unloading: entities stay in memory, disabled and hidden, so
re-entering is instant at the cost of memory. Cell scenes are ordinary project
scenes referenced by name (see below).
Under the resource sits SceneStreamingController — pure orchestration over the
SceneManager primitives (loadAdditive / unload / sleep / wake); it owns
no loading itself. Distances are measured to the cell edge (center distance
minus radius), so a large cell starts loading while its center is still far
away. Loads and unloads are async, and the controller keeps them safe: a cell
with a load or unload in flight is never issued twice, and if the focus has
already left by the time a load resolves, the cell is dropped again instead of
leaking. With no cells registered, update() is a no-op.
SceneStreamingController |
Description |
|---|---|
configure({ loadRadius, unloadRadius, policy? }) |
Set the radii (unloadRadius is clamped up to loadRadius) and the out-of-range policy — 'unload' (default) or 'sleep'. |
register(cell) / unregister(scene) / clear() |
Manage the cell set ({ scene, x, y, radius }). |
setFocus(x, y) |
Drive the focus point directly (call each frame). |
setFocusEntity(entity) / getFocusEntity() |
Follow an entity instead — the streaming system reads its Transform each tick (play mode only). |
getActive() |
Scene names the controller currently holds in-range. |
update() |
Reconcile resident cells against the focus (sceneManagerPlugin calls this every frame). |
The threshold math is also exported on its own as the pure
computeStreaming(cells, focusX, focusY, loadRadius, unloadRadius, active) —
useful for tests or a custom scheduler.
Scenes in a build
Section titled “Scenes in a build”Every scene in the project’s scenes folder ships with an export and registers as a
switchTo target. A scene’s name is its path relative to the scenes folder,
without the .esscene extension — assets/scenes/levels/boss.esscene is
switchTo('levels/boss'). The startup scene loads eagerly; the others are fetched
on their first switch, so extra scenes don’t slow the initial load.
Scenes you untick in the Package dialog’s Scenes in build list are left out of the export (they stay editable and playable in the editor), and Playable-ad builds ship the startup scene only. See Building & Exporting.
SceneManager reference
Section titled “SceneManager reference”| Method | Description |
|---|---|
switchTo(name, options?) |
Replace the active scene (async; optional transition). |
loadAdditive(name, onProgress?) |
Load a scene on top; returns its context (async). |
unload(name, options?) |
Unload a scene (async; optional transition). |
getActive() |
The active scene name, or null. |
getActiveScenes() |
All active (additive) scene names. |
pause(name) / resume(name) |
Suspend / resume a loaded scene’s updates. |
sleep(name) / wake(name) |
Suspend and hide a scene’s entities (kept in memory; waking restores them instantly). |
isLoaded(name) |
Whether a scene is loaded. |
isActive(name) |
Whether a scene is active. |
isPaused(name) / isSleeping(name) |
The suspension states above. |
isTransitioning() |
true during a switchTo transition. |
Serialization & migration
Section titled “Serialization & migration”The scene file format is a public surface: SceneData is plain JSON, and the
primitives the editor and runtime use to read and write it are exported for game
code. You reach for them to save a snapshot of the live world (a checkpoint
richer than the save-game API), to load player-generated
or downloaded scenes, or to ship components whose state doesn’t fit their plain
fields.
serializeScene(world, name?) walks the live world and produces a SceneData
that round-trips through the loaders — hierarchy is collapsed into each entity
record’s parent/children fields, and runtime-only projection entities (tile
colliders and the like, which their systems re-derive) are excluded.
loadSceneWithAssets(world, data, options?) preloads every referenced asset,
then spawns; the sync loadSceneData(world, data) spawns without asset
resolution. Both return a map from serialized entity ids to live entities, and
findEntityByName(world, name) finds an entity afterwards:
import { defineSystem, GetWorld, Res, Assets, serializeScene, loadSceneWithAssets, findEntityByName, MissingAssetsError } from 'esengine';
const checkpoint = defineSystem([GetWorld(), Res(Assets)], async (world, assets) => { // Save: snapshot the live world as ordinary scene JSON. const data = serializeScene(world, 'checkpoint'); localStorage.setItem('checkpoint', JSON.stringify(data));
// Load: preload assets, then spawn. Old saves are migrated automatically. try { await loadSceneWithAssets(world, JSON.parse(localStorage.getItem('checkpoint')!), { assets, abortOnMissingAssets: true, // throw instead of spawning with holes onMissingAssets: (missing) => { // called once per load (list may be empty) for (const m of missing) console.warn(`${m.ref}: ${m.reason}`); }, }); } catch (e) { if (e instanceof MissingAssetsError) { /* "content missing" UI — e.missing */ } }
const player = findEntityByName(world, 'Player');});SceneLoadOptions:
| Option | Description |
|---|---|
assets |
The Assets resource; enables asset preloading (and prefab-instance expansion) before spawn. |
assetBaseUrl |
Base URL to resolve asset paths against. |
onProgress |
(loaded, total) preload progress callback. |
onMissingAssets |
Called once with every ref that failed to resolve or load (MissingAsset[] — ref, optional type, and reason: 'unresolved' | 'load-failed'). Fires even when the list is empty. |
abortOnMissingAssets |
Default false — missing assets get handle 0 and the scene loads anyway. true throws MissingAssetsError after preloading, before anything spawns. |
collectAssets |
Bookkeeping sets the load adds acquired asset keys to, so the caller (normally the scene manager) can release them on unload. |
Versioning. Scene files carry a version (currently
SCENE_FORMAT_VERSION = '1.0'). migrateSceneData(raw) upgrades any older
shape to the current one: it never mutates its input (it returns a migrated deep
copy), is idempotent, drops component types retired by engine upgrades, and
rejects data newer than the engine. Every loader runs it internally — call
it yourself only to inspect migrated / fromVersion (say, to prompt a re-save
of an upgraded file):
import { migrateSceneData } from 'esengine';
const { data, migrated, fromVersion } = migrateSceneData(raw);if (migrated) console.log(`upgraded scene from format ${fromVersion}`);Out-of-band component state. A component whose full state doesn’t fit its
plain field record — a tilemap layer’s tile chunks live in a C++ blob — registers
a SceneComponentCodec so the generic (de)serializer can carry it without
component-specific knowledge:
import { registerSceneComponentCodec } from 'esengine';
registerSceneComponentCodec('MyVoxelChunk', { exportData(entity, data) { data.chunks = myStore.serialize(entity); }, outOfBandFields: ['chunks'], // stripped before the component insert importData(entity, outOfBand) { myStore.restore(entity, outOfBand.chunks); },});exportData writes the extra state into the record on save; the listed
outOfBandFields are stripped from the record before the component is inserted
on load, then handed to importData afterwards. Registration is idempotent —
the owning plugin (see tilemapPlugin) does it at startup.
Best practices
Section titled “Best practices”awaitscene calls — they’re async; a switch that isn’t awaited races with the rest of your frame.- Additive for overlays (HUD, pause, dialogs) so gameplay stays loaded underneath.
- Gate logic with systems, not flags — systems declared in a scene only run while it’s active, so an additive HUD and the gameplay scene update independently.
- Tag long-lived entities deliberately: because scenes share one world, an untagged entity can outlive a scene switch.
- Persist across scenes with saves, not by leaking entities.
See also
Section titled “See also”- Prefabs — spawn reusable entity trees within a scene.
- Saving & Loading — persist state across scene switches.
- Assets — scenes are cook entry points.