# Scene Runtime

> Cross-cutting runtime APIs for Decentraland SDK7 scenes. Use when the user needs async work (executeTask), HTTP (fetch/signedFetch) or WebSocket, timers, realm/scene metadata, restricted actions (movePlayerTo, teleport, emotes, external URLs), system execution order/priority, or to write scene tests. Do NOT use for UI (see build-ui), multiplayer sync (see multiplayer-sync), avatar/player data (see player-avatar), or polling-based input (see advanced-input).

- Skill: `decentraland/scene-runtime` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add decentraland/scene-runtime`
- Raw SKILL.md: https://api.skillmd.com/api/skills/decentraland/scene-runtime/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: decentraland (https://skillmd.com/u/decentraland)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/decentraland/scene-runtime

---


# 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:

```typescript
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:

```typescript
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):

```typescript
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 in `scene.json` `requiredPermissions`. In practice plain/signed `fetch` to non-media hosts is not hard-blocked in preview/Worlds even without it (the `66,6-signed-fetch` test scene calls `signedFetch` with an empty `requiredPermissions`). Declare `USE_FETCH` anyway for correctness and forward-compat. `signedFetch` does not require prior player interaction — restricted actions do, `fetch`/`signedFetch` do not.

## WebSocket

```typescript
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

```typescript
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

```typescript
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:

```typescript
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`:

```typescript
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.

```typescript
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 `sceneHidden` is `true` — 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 `done` flag) after it fires; otherwise it re-runs every frame.
- On Explorer builds that predate the field, `sceneHidden` stays at its proto default `false`, 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 priority `1` runs almost LAST.

Key numbers:

- **Default priority is `100000`** (`SYSTEMS_REGULAR_PRIORITY = 100e3`). `engine.addSystem(fn)` with no priority uses this.
- `@dcl/react-ecs` registers its UI renderer system at `100000` (and a UI-scale system at `100001`). 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 **below** `100000` (e.g. `10`, or the default `0` used by scripts).

```typescript
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`:

```typescript
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.

```typescript
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.

```typescript
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.

```typescript
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:

```ts
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):

```typescript
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:

```typescript
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 through `reservedStaticEntities - 1` at 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:

```typescript
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`.

```typescript
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 is `ens`/`pid`, **not `urn`**.
- `kill({ pid })` returns `{ status: boolean }`; `kill({ pid })` and `getPortableExperiencesLoaded({})` both key off `pid`, never `urn`.
- 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.

```typescript
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 check
- `assert(condition, message?)` — truthiness check
- `assertComponentValue(entity, Component, expected)` — full component value comparison
- `deepCloseTo(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 — `signedFetch` on click; reads `response.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 own `scene.json` at runtime: `JSON.parse(sceneInfo.metadataJson)`, then walking `worldConfiguration.skyboxConfig` with a fallback to top-level `skyboxConfig`. The sibling https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/3,0-skybox-world-json is the same code against a `worldConfiguration` deployment.
- https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/80,-4-restricted-actions — every RestrictedAction via UI buttons: `movePlayerTo` (with `cameraTarget` and `avatarTarget`), `teleportTo`, `triggerEmote`, `triggerSceneEmote`, `openExternalUrl`, `openNftDialog`, `changeRealm` (with and without `message`).
- https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/8,8-portable-experience — `spawn({ ens })` / `kill({ pid })` from the spawn response; host `scene.json` has `portableExperiences: "enabled"`.
- https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/8,9-portable-experience-disabled — same, but host `scene.json` sets `portableExperiences: "disabled"` (spawn suppressed).
- https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/8,7-portable-experience-hide-ui — host `scene.json` sets `portableExperiences: "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`.

