Catalogskills.sh · decentraland

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 (teleportTo / realm change, external URLs, NFT dialog, explorer panels, clipboard), 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).

Review advisedVerified hub289 installs

Savant verdict: Review advised

Security analysis or source-hub review identified patterns requiring attention.

Live evaluation

Not evaluated yet. Workspaces can request a live evaluation.

Security assessment

Scan pending.

Structure

  • No license declaredConfirm you may reuse this skill before importing it into your repository.
  • The source hub's security checks flagged this skillSnyk: warn (Risk: MEDIUM · 1 issue)

SKILL.md

---
name: scene-runtime
description: 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 (teleportTo / realm change, external URLs, NFT dialog, explorer panels, clipboard), 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).
---

# Scene Runtime APIs

## Async Tasks

Scenes run in a single-threaded QuickJS sandbox with no Node.js APIs (`fs`, `http`, `path`, `process`). 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**: a scene needs none — scenes and portable experiences run with every permission granted. Only a **smart wearable** must declare `"USE_FETCH"` for `fetch` (and `"USE_WEBSOCKET"` for sockets) in `requiredPermissions`, or the call fails with `Fetch API: permission denied`; `signedFetch` is gated by no permission (**smart-wearables** → Permissions). `fetch` and `signedFetch` need no prior player interaction.

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


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

## 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 sort descending), the opposite of Unity/Godot. "Make this run first" means a **large** number; 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

They run only while the player is inside the scene. `openExplorerUi` also needs a fresh player gesture (see its section); `openExternalUrl`, `teleportTo`, `changeRealm` and `openNftDialog` ask the player to confirm first. Player movement and emotes (`movePlayerTo`, `triggerEmote`, `triggerSceneEmote`, `stopEmote`): **player-avatar**. Import from `~system/RestrictedActions`:

```typescript
import {
  teleportTo,
  changeRealm,
  openExternalUrl,
  openNftDialog,
  copyToClipboard,
  setCommunicationsAdapter,
  openExplorerUi,
} from "~system/RestrictedActions";

// Teleport to coordinates in Genesis City
teleportTo({ worldCoordinates: { x: 50, y: 70 } });

// Teleport into another realm / World. `realm` takes a World name
// (`foo.dcl.eth`) or a realm URL; the client does a FULL RECONNECT to
// that realm, then lands on the parcel. Requires @dcl/sdk 7.28.0+.
// The player is asked to confirm first ("Are you sure you want to enter
// this World?"); the switch only happens if they accept.
teleportTo({ realm: "foo.dcl.eth", worldCoordinates: { x: 0, y: 0 } });

// Omit worldCoordinates to land on that realm's default spawn.
// This replaces changeRealm.
teleportTo({ realm: "foo.dcl.eth" });

// 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!" });

// [DEPRECATED] changeRealm — use teleportTo({ realm }) instead.
// The player always gets a confirmation dialog. `message` is OPTIONAL:
// omit it to show the default text, include it to show custom text.
changeRealm({ realm: "https://peer.decentraland.org" }); // default prompt text
changeRealm({ realm: "other-realm.dcl.eth", message: "Join this realm?" }); // custom prompt text
```

#### `changeRealm` is `[DEPRECATED]` — prefer `teleportTo({ realm })`

`changeRealm` resolves when the request is *accepted*, not when the new realm is *live*, so `changeRealm(...)` followed by `teleportTo(coords)` lands on the parcel in the **old** realm. Use one `teleportTo({ realm, worldCoordinates })` call. `changeRealm` still works; it is the only one that sets custom prompt text (`message`). Signature: `{baseDir}/references/runtime-apis.md` → teleportTo.

### 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 });
```

The call resolves to `{ openResult }`, an `OpenExplorerUiResult` (also from `~system/RestrictedActions`): `OPENED`, `WAS_ALREADY_OPEN` (the slot that panel uses is already taken; panels sharing a slot exclude each other, so another panel or section may be the one showing), `REJECTED_NOT_CURRENT_SCENE` (the player is not in this scene), `REJECTED_FEATURE_DISABLED` (the client hides or does not support that panel), `REJECTED_NO_USER_GESTURE` (not called from a player interaction), plus the default `UNSPECIFIED`. Call it from an input callback: called at top level it always returns `REJECTED_NO_USER_GESTURE`. Treat only `OPENED` as success: on anything else, show the information another way or tell the player what to open.

```typescript
import { openExplorerUi, OpenExplorerUiResult } from "~system/RestrictedActions";
import { ExplorerUi, InputAction, pointerEventsSystem } from "@dcl/sdk/ecs";

pointerEventsSystem.onPointerDown(
  { entity: placesSign, opts: { button: InputAction.IA_POINTER, hoverText: "Places" } },
  async () => {
    const { openResult } = await openExplorerUi({ ui: ExplorerUi.EU_PLACES });
    if (openResult !== OpenExplorerUiResult.OPENED) {
      showHint("Open Places from the menu to find more scenes"); // your own UI fallback
    }
  },
);
```

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

## Timers

**Always use the engine-bound `timers` object from `@dcl/sdk/ecs`** (`timers.setTimeout`, `timers.setInterval`). QuickJS also exposes the JS-standard globals, but they are not bound to the scene's engine.

```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)`**, as in standard JS.

**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.

For a custom engine instance, use `createTimers(engineInstance)` from `@dcl/sdk/ecs` to get a `Timers` object scoped to that engine.

**Callbacks vs per-frame state.** A one-shot or periodic **callback** (do X in 2 s, every 1 s) is `timers.setTimeout` / `setInterval`; `timers` is itself an engine system that accumulates `dt`, so it is already frame-synchronized. **Per-frame game state** you update or read every tick (a cooldown bar, regeneration, a countdown shown in UI) is a `dt` accumulator inside your own system:

```typescript
let cooldown = 0; // seconds left; a UI bar reads it every frame
engine.addSystem((dt: number) => {
  if (cooldown > 0) cooldown = Math.max(0, cooldown - dt);
});
```

## 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` (`@dcl/sdk` 7.27.0+; earlier SDKs return `void` and can purge a live remote avatar's components):
- `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).

**Gotcha — avatar entity collision:** `engine.removeEntity` refuses renderer-reserved ids, so it cannot purge a live remote player's avatar entity. 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.

## Portable Experiences

Spawning, listing and killing portable experiences from a scene (`~system/PortableExperiences`, keyed by `ens` / `pid`, never `urn`), and the host scene's `featureToggles.portableExperiences`: `{baseDir}/references/runtime-apis.md` → Portable Experiences. Building one that runs while a wearable is equipped: **smart-wearables**.

## Testing Framework

Writing scene tests with `@dcl/sdk/testing` (generator tests, the four assertions, where tests run): `{baseDir}/references/testing.md`.

## 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).

`setCommunicationsAdapter`, `teleportTo({ realm })` details and extra `executeTask` variants (error handling, sequential): `{baseDir}/references/runtime-apis.md`.