Scene Runtime APIs
Cross-cutting runtime APIs available in every Decentraland SDK7 scene.
Async Tasks
The scene runtime is single-threaded. Wrap any async work in executeTask() (or an async function) — bare promises are silently dropped:
import { executeTask } from "@dcl/sdk/ecs";
executeTask(async () => {
const res = await fetch("https://api.example.com/data");
const data = await res.json();
console.log(data);
});
HTTP: fetch & signedFetch
Plain fetch works for public APIs:
const res = await fetch("https://api.example.com/data");
signedFetch proves the player's identity to your backend. Use getHeaders() to obtain only the signed headers (useful when a library manages its own fetch):
import { signedFetch, getHeaders } from "~system/SignedFetch";
// Full signed request
const res = await signedFetch({
url: "https://your-server.com/api",
init: { method: "POST", body: JSON.stringify(payload) },
});
// Get signed headers only (for custom fetch calls)
const { headers } = await getHeaders({ url: "https://your-server.com/api" });
signedFetch returns { ok, status, statusText, headers, body } where body is a string (call JSON.parse(response.body) yourself — there is no .json()). The signed identity headers are added automatically; your backend verifies them per ADR-44.
Permission: the nominal permission for external HTTP is
"USE_FETCH"(and"USE_WEBSOCKET"for sockets), declared inscene.jsonrequiredPermissions. In practice plain/signedfetchto non-media hosts is not hard-blocked in preview/Worlds even without it (the66,6-signed-fetchtest scene callssignedFetchwith an emptyrequiredPermissions). DeclareUSE_FETCHanyway for correctness and forward-compat.signedFetchdoes not require prior player interaction — restricted actions do,fetch/signedFetchdo not.
WebSocket
const ws = new WebSocket("wss://your-server.com/ws");
ws.onopen = () => ws.send("hello");
ws.onmessage = (event) => console.log(event.data);
ws.onclose = () => console.log("disconnected");
Scene & Realm Information
import { getSceneInformation, getRealm, getExplorerInformation } from "~system/Runtime";
executeTask(async () => {
// Scene info: URN, content mappings, metadata JSON, baseUrl
const scene = await getSceneInformation({});
const metadata = JSON.parse(scene.metadataJson);
// metadata.scene?.parcels (parcel list), metadata.display?.title (scene title)
console.log(scene.urn, scene.baseUrl, metadata);
// Realm info: baseUrl, realmName, isPreview, networkId (1 = mainnet, 5 = goerli), commsAdapter
const realm = await getRealm({});
console.log(realm.realmInfo?.realmName, realm.realmInfo?.isPreview); // realmName e.g. "peer-us-1"
// Explorer info: agent string, platform, configurations
const explorer = await getExplorerInformation({});
console.log(explorer.agent, explorer.platform);
});
Check realm.realmInfo?.isPreview to detect preview mode and gate debug features.
World Time
import { getWorldTime } from "~system/Runtime";
executeTask(async () => {
const { seconds } = await getWorldTime({});
// seconds = coordinated world time (cycles 0-86400 for day/night)
});
Read Deployed Files
Read files deployed with the scene at runtime — use it for data files like JSON configs or level data:
import { readFile } from "~system/Runtime";
executeTask(async () => {
const result = await readFile({ fileName: "data/config.json" });
const text = new TextDecoder().decode(result.content);
const config = JSON.parse(text);
});
EngineInfo Component
Frame-level timing plus scene visibility, on engine.RootEntity:
import { EngineInfo, engine } from "@dcl/sdk/ecs";
engine.addSystem(() => {
const info = EngineInfo.getOrNull(engine.RootEntity);
if (info) {
console.log(info.frameNumber, info.tickNumber, info.totalRuntime);
// info.sceneHidden: true when a fullscreen Explorer UI (map, backpack,
// settings, loading screen, etc.) covers the scene viewport.
if (info.sceneHidden) {
// Pause expensive work, audio, animations — the player cannot see or
// interact with the scene while it is hidden.
}
}
});
EngineInfo fields
| Field | Type | Description |
|---|---|---|
frameNumber |
number |
Frame counter of the engine |
tickNumber |
number |
Tick counter of the scene (per ADR-148) |
totalRuntime |
number |
Total runtime of this scene in seconds |
sceneHidden |
boolean |
true when the scene is hidden behind a fullscreen Explorer UI (map, backpack, settings, camera reel, loading screen). Written at the "physics" stage alongside frameNumber/tickNumber. Use to pause gameplay, audio, animations, and expensive systems when the scene is not visible. Default false. |
Verified against protocol commit 0b3d285 (field 4 bool scene_hidden in PBEngineInfo, component id 1048) and js-sdk-toolchain commit ffb26183 (exposed as sceneHidden: boolean on PBEngineInfo).
Detecting when the loading screen fades out
sceneHidden is true while the Explorer's loading screen covers the scene and flips to false the moment it fades out. This is the only signal a scene gets for the first moment the player actually sees it. onSceneReady-style timing based on totalRuntime, a frame counter, or a utils.timers delay is guesswork — the loading screen lasts as long as it lasts, and varies per player and per machine.
Use it to hold back anything the player is meant to witness: intro cinematics, welcome sounds/VO, an opening tween, a title UI, or an analytics event that shouldn't fire while the player is still staring at a loading screen.
import { EngineInfo, engine } from "@dcl/sdk/ecs";
engine.addSystem(function waitForSceneRevealed() {
const info = EngineInfo.getOrNull(engine.RootEntity);
if (!info || info.sceneHidden) return;
engine.removeSystem(waitForSceneRevealed); // one-shot
// the player is now looking at the scene — start the intro here
});
- The scene keeps ticking normally while
sceneHiddenistrue— it is not paused, just not displayed. Don't use this flag to gate scene logic, only to time presentation. - Always guard with
getOrNull— the component may not exist on the very first frames. - Remove the system (or set a
doneflag) after it fires; otherwise it re-runs every frame. - On Explorer builds that predate the field,
sceneHiddenstays at its proto defaultfalse, so this pattern degrades to "fire as soon as possible" rather than hanging.
System Execution Order & Priority
engine.addSystem(fn, priority?, name?) runs fn(dt) every frame. The priority parameter controls when in the frame it runs relative to other systems.
HIGHER priority number = runs EARLIER in the frame. Systems are sorted descending by priority (sort((a, b) => b.priority - a.priority) in @dcl/ecs). The SDK's own JSDoc states: "a number with the priority, big number are called before smaller ones."
WARNING — counter-intuitive: This is the OPPOSITE of Unity/Godot/many engines where a lower number runs first. In Decentraland SDK7, "make this run first" means giving it a large priority number, NOT
1. A system with priority1runs almost LAST.
Key numbers:
- Default priority is
100000(SYSTEMS_REGULAR_PRIORITY = 100e3).engine.addSystem(fn)with no priority uses this. @dcl/react-ecsregisters its UI renderer system at100000(and a UI-scale system at100001). So UI runs alongside/just before default-priority systems.- To run before all regular systems, pass a priority above
100000(e.g.engine.addSystem(fn, 1000000)). To run after them, pass a priority below100000(e.g.10, or the default0used by scripts).
engine.addSystem(earlySystem, 1000000); // runs before regular systems
engine.addSystem(regularSystem); // priority 100000 (default)
engine.addSystem(lateSystem, 10); // runs after regular systems
- Physics libraries (e.g. cannon.js) stepped inside a system's
update(dt)obey the same rule — there is no separate physics loop. Give the physics-stepping system a high priority if other systems must read post-step state the same frame. - Systems with the same priority currently run in insertion order, but the engine does not guarantee a stable tie-break (noted as a TODO in the source). Do not rely on ordering between equal-priority systems.
Restricted Actions
These require prior player interaction (e.g. a click) before they can execute. Import from ~system/RestrictedActions:
import {
movePlayerTo,
teleportTo,
triggerEmote,
changeRealm,
openExternalUrl,
openNftDialog,
triggerSceneEmote,
copyToClipboard,
setCommunicationsAdapter,
openExplorerUi,
} from "~system/RestrictedActions";
// Move player within scene bounds. Optional: cameraTarget (where the
// CAMERA looks), avatarTarget (where the AVATAR faces — for rotating in
// place), duration (seconds, for a smooth glide instead of a snap).
movePlayerTo({
newRelativePosition: { x: 8, y: 0, z: 8 },
cameraTarget: { x: 8, y: 1, z: 12 },
});
// Teleport to coordinates in Genesis City
teleportTo({ worldCoordinates: { x: 50, y: 70 } });
// Play a built-in emote
triggerEmote({ predefinedEmote: "wave" });
// Open URL in browser (prompts user)
openExternalUrl({ url: "https://decentraland.org" });
// Open NFT detail dialog
openNftDialog({
urn: "urn:decentraland:ethereum:erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d:558536",
});
// Copy text to clipboard
copyToClipboard({ text: "Hello from Decentraland!" });
// Change realm. `message` is OPTIONAL: omit it to switch with no prompt,
// include it to show the player a confirmation dialog first.
changeRealm({ realm: "https://peer.decentraland.org" }); // no prompt
changeRealm({ realm: "other-realm.dcl.eth", message: "Join this realm?" });
openExplorerUi -- Open Explorer Panels
Open a fullscreen explorer panel (map, backpack, settings, etc.) from scene code. Requires prior player interaction.
import { openExplorerUi } from "~system/RestrictedActions";
import { ExplorerUi } from "@dcl/sdk/ecs";
// Open the map panel
openExplorerUi({ ui: ExplorerUi.EU_MAP });
ExplorerUi enum values (imported from @dcl/sdk/ecs): EU_SETTINGS (0), EU_MAP (1), EU_BACKPACK (2), EU_CAMERA_REEL (3), EU_COMMUNITIES (4), EU_PLACES (5), EU_EVENTS (6). The enum is shared between openExplorerUi and ExplorerUiEventsResult -- no casts needed when comparing.
ExplorerUiEventsResult -- Observe Panel Open/Close
Scenes can observe when explorer panels are opened or closed. ExplorerUiEventsResult is a grow-only value set (APPEND semantics, max 100 entries) on engine.RootEntity. Each entry reports which panel and whether it opened or closed.
import { engine, ExplorerUiEventsResult, ExplorerUi } from "@dcl/sdk/ecs";
engine.addSystem(() => {
const results = ExplorerUiEventsResult.get(engine.RootEntity);
for (const entry of results.values()) {
if (entry.ui === ExplorerUi.EU_MAP) {
if (entry.event?.$case === "opened") {
console.log("Map was opened");
} else if (entry.event?.$case === "closed") {
console.log("Map was closed");
}
}
}
});
Each entry has: ui (ExplorerUi enum), timestamp (scene tick), event (oneof: { $case: 'opened' } or { $case: 'closed' }). Component id 1220. Verified against protocol commit 86c4613 and js-sdk-toolchain commit fc8cfc65.
Timers
Always use the engine-bound timers object from @dcl/sdk/ecs. Do NOT use the native JS setTimeout / setInterval globals. Although the QuickJS runtime exposes JS-standard setTimeout / clearTimeout / setInterval / clearInterval as globals (declared in @dcl/js-runtime/index.d.ts), calling them in a Decentraland scene may appear to work but can introduce subtle problems — they are not bound to the scene's engine. Use timers.setTimeout instead.
import { timers } from "@dcl/sdk/ecs";
const timeoutId = timers.setTimeout(() => console.log("delayed"), 2000);
timers.clearTimeout(timeoutId);
const intervalId = timers.setInterval(() => console.log("tick"), 1000);
timers.clearInterval(intervalId);
The signatures match the JS-standard timers:
timers.setTimeout(callback: () => void, ms: number): number
timers.clearTimeout(timerId: number): void
timers.setInterval(callback: () => void, ms: number): number
timers.clearInterval(timerId: number): void
Argument order is (callback, ms) — not (ms, callback). Do NOT write a custom helper that flips them.
Timer error handling: if a timer callback throws, subsequent timers still measure correctly. The SDK clears the internal timing context via try/finally so a thrown exception in one callback does not corrupt elapsed-time tracking for later timers. Verified against js-sdk-toolchain commit a2ccd0b1.
Do NOT write a custom per-frame timer system that accumulates dt to fire delayed callbacks. The SDK already ships timers. Custom systems duplicate work, drift from the engine's own scheduling, and are the wrong abstraction for one-shot delays.
For a custom engine instance, use createTimers(engineInstance) from @dcl/sdk/ecs to get a Timers object scoped to that engine.
System-based timers (recommended for game logic — synchronized with the frame loop):
let elapsed = 0;
engine.addSystem((dt: number) => {
elapsed += dt;
if (elapsed >= 3) {
elapsed = 0;
// Do something every 3 seconds
}
});
Component.onChange() Listener
React to component changes on any entity:
Transform.onChange(engine.PlayerEntity, (newValue) => {
if (newValue) {
console.log("Player moved to", newValue.position);
}
});
Entity Removal
engine.removeEntity(entity): boolean
Removes all components from an entity and releases its id for reuse. Returns boolean:
true— entity accepted; components purged, id released for recycling.false— entity refused; components untouched, id stays reserved. This happens for entity ids in the renderer-reserved range (avatar entities, numbers 3 throughreservedStaticEntities - 1at any version). The three named static entities (engine.RootEntity,engine.PlayerEntity,engine.CameraEntity) are also reserved and never released, but their components are still purged (the renderer accepts scene deletes on those three).
The return type changed from void to boolean as of js-sdk-toolchain commit e712ef71. Existing code that ignores the return value is unaffected.
Gotcha — avatar entity collision: before this fix, engine.removeEntity could silently purge components of a live remote player's avatar entity. The engine now refuses removal of renderer-reserved ids, preventing this. Never call removeEntity on an entity returned by iterating PlayerIdentityData unless you specifically intend to clear a named static entity.
removeEntityWithChildren
Recursively remove an entity and all its children — reach for this when cleaning up complex entity hierarchies:
import { removeEntityWithChildren } from "@dcl/sdk/ecs";
removeEntityWithChildren(engine, parentEntity);
Caveat — partial completion: removeEntityWithChildren can complete only partially without reporting it. If a renderer-reserved node (e.g. an avatar entity) appears anywhere in the Transform tree, that node's removal is refused by removeEntity while its descendants are still removed, leaving the surviving node's Transform.parent pointing at a removed entity. This is reachable only if a scene parents a reserved entity under a scene entity. The function returns void — there is no per-node result. Verified against js-sdk-toolchain commit e712ef71.
Portable Experiences
Scenes that persist across world navigation. Import from ~system/PortableExperiences.
import {
spawn,
kill,
exit,
getPortableExperiencesLoaded,
} from "~system/PortableExperiences";
// Spawn by ENS name (a deployed World) OR by pid. NOT by "urn".
const result = await spawn({ ens: "boedo.dcl.eth" });
// result: { pid, parentCid, name, ens }
// Kill a running one by its pid (from the spawn response). NOT by urn.
if (result.pid) await kill({ pid: result.pid });
// List currently loaded portable experiences
const { loaded } = await getPortableExperiencesLoaded({});
// Exit self (only if THIS scene IS a portable experience)
await exit({});
spawn({ ens?, pid? })→SpawnResponse { pid, parentCid, name, ens? }. Field isens/pid, noturn.kill({ pid })returns{ status: boolean };kill({ pid })andgetPortableExperiencesLoaded({})both key offpid, neverurn.- The host scene must enable them in
scene.json:"featureToggles": { "portableExperiences": "enabled" }. Values:"enabled"|"disabled"|"hideUi"(spawns PX but hides their UI). With"disabled",spawn()is a no-op / rejected.
Testing Framework
Scenes can ship unit tests using @dcl/sdk/testing. Tests are generators — yielding pauses until the next frame so you can observe engine state across ticks.
import { test } from "@dcl/sdk/testing";
import {
assertComponentValue,
assertEquals,
} from "@dcl/sdk/testing/assert";
import { engine, Transform, MeshRenderer } from "@dcl/sdk/ecs";
import { Vector3, Quaternion } from "@dcl/sdk/math";
test("transform is applied after one frame", function* () {
const entity = engine.addEntity();
Transform.create(entity, { position: Vector3.One() });
// Let the engine run for a frame before asserting
yield;
assertComponentValue(entity, Transform, {
position: Vector3.One(),
scale: Vector3.One(),
rotation: Quaternion.Identity(),
parent: 0 as any,
});
});
test("five meshes are present", function* () {
yield;
assertEquals(1 + 1, 2, "basic math");
// No count assertion exists — count via getEntitiesWith + Array.from
assertEquals(
Array.from(engine.getEntitiesWith(MeshRenderer)).length,
5,
"should have 5 meshes"
);
});
Available assertions (@dcl/sdk/testing/assert) — exactly these four:
assertEquals(actual, expected, message?)— deep-equals checkassert(condition, message?)— truthiness checkassertComponentValue(entity, Component, expected)— full component value comparisondeepCloseTo(actual, expected, options?)— deep numeric comparison with tolerance (for floats)
There is NO count assertion — count entities with assertEquals(Array.from(engine.getEntitiesWith(Comp)).length, n).
Running tests: there is no CLI test command (npx @dcl/sdk-commands test does not exist). Tests execute only when the hosting runtime exposes the ~system/Testing module — CI test runners or test-enabled explorers. In a normal preview the test runner is a no-op that just logs, and it's guarded behind DEBUG in production builds. Tests run inside the same QuickJS runtime as the scene, so the same restrictions apply (no Node.js APIs, use SDK timers, etc.).
Logging
Only console.log() and console.error() are declared in the runtime — console.warn(), .info(), .debug(), .trace() are NOT available.
Example scenes
Engine-team test scenes exercising these APIs against the real runtime:
- https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/66,6-signed-fetch —
signedFetchon click; readsresponse.ok/.status/.body, inspects the auto-added signed headers. - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/2,0-skybox-scene-json —
executeTask+getSceneInformation({})reading the scene's ownscene.jsonat runtime:JSON.parse(sceneInfo.metadataJson), then walkingworldConfiguration.skyboxConfigwith a fallback to top-levelskyboxConfig. The sibling https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/3,0-skybox-world-json is the same code against aworldConfigurationdeployment. - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/80,-4-restricted-actions — every RestrictedAction via UI buttons:
movePlayerTo(withcameraTargetandavatarTarget),teleportTo,triggerEmote,triggerSceneEmote,openExternalUrl,openNftDialog,changeRealm(with and withoutmessage). - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/8,8-portable-experience —
spawn({ ens })/kill({ pid })from the spawn response; hostscene.jsonhasportableExperiences: "enabled". - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/8,9-portable-experience-disabled — same, but host
scene.jsonsetsportableExperiences: "disabled"(spawn suppressed). - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/8,7-portable-experience-hide-ui — host
scene.jsonsetsportableExperiences: "hideUi"(PX run, their UI hidden).
Full RestrictedActions reference — triggerSceneEmote (_emote.glb requirement), setCommunicationsAdapter, movePlayerTo rotate-in-place, predefined emote names — plus extra executeTask variants (error handling, sequential): {baseDir}/references/runtime-apis.md.