skillZs
★ LIVE SKILL TAGS ★
>>> LIVE SKILLS INDEX <<<
* OPEN SOURCE *
NO LOGIN, NO TRACKING
※ REAL INSTALL DATA ※
← back to all skills
decentraland/sdk-skills216 installs

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

How do I install this agent skill?

npx skills add https://github.com/decentraland/sdk-skills --skill scene-runtime
view source ↗

Is this agent skill safe to install?

  • Gen Agent Trust Hubpass

    This skill provides official documentation for the Decentraland SDK7 runtime APIs. It covers network communication, file system access, and restricted player actions. The skill adheres to security best practices by explicitly documenting requirements for player interaction for sensitive operations and providing secure identity verification patterns. All external links point to trusted repositories owned by the vendor.

  • Socketpass

    No alerts

  • Snykpass

    Risk: LOW · No issues

What does this agent skill do?

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

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

FieldTypeDescription
frameNumbernumberFrame counter of the engine
tickNumbernumberTick counter of the scene (per ADR-148)
totalRuntimenumberTotal runtime of this scene in seconds
sceneHiddenbooleantrue 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 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).
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 } });

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

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

// [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 })

Protocol e89d7fa, SDK pin 6b7c3586; the realm field is typed in @dcl/js-runtime 7.28.0.

  • TeleportToRequest is now { worldCoordinates?: Vector2, realm?: string } — both optional.
  • realm accepts a World name (foo.dcl.eth) or a realm URL. When set, the client first shows the player a confirmation prompt with the default text ("Are you sure you want to enter this World?"). If the player accepts, the client performs a full reconnect to that realm (even if it is the realm the player is already in), then lands on the parcel.
  • Omit worldCoordinates with a realm set to land on that realm's default spawn — exactly what changeRealm did.
  • Omit realm to teleport within the player's current realm (the pre-existing behavior).

Why it replaces changeRealm: changeRealm resolves when the request is accepted, not when the new realm is live. A changeRealm(...) followed by a teleportTo(coords) therefore lands on the parcel in the old realm. One teleportTo({ realm, worldCoordinates }) call has no such race.

changeRealm still works and is kept for existing scenes. Both changeRealm and teleportTo({ realm }) ask the player to confirm before switching realm; changeRealm is the only one that lets the scene set custom prompt text through message.

The Unity explorer honors the realm field.

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

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

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:

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.

Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.

<a href="https://skillzs.dev/skills/decentraland/sdk-skills/scene-runtime">View scene-runtime on skillZs</a>