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

script-components

Writing .ts script files for the Creator Hub Script component — self-contained classes attached to individual entities. Use when the user wants to create a custom smart item, a reusable scripted entity, or write code that runs on a Creator Hub Script component. Do NOT use for regular scene index.ts code or global systems (see scene-runtime, add-interactivity).

How do I install this agent skill?

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

Is this agent skill safe to install?

  • Gen Agent Trust Hubpass

    The skill provides comprehensive documentation for writing script components in Decentraland's Creator Hub. A minor security risk was identified regarding indirect prompt injection where script parameters or event metadata could potentially influence AI-assisted code generation features within the platform's editor.

  • Socketpass

    No alerts

  • Snykpass

    Risk: LOW · No issues

What does this agent skill do?

Writing Script Components for Creator Hub

This document explains how to write .tsx files that are used inside a Script component on an entity in a Creator Hub scene. These scripts run as self-contained classes attached to individual entities.

Where script files must live

Script files referenced by a Script component MUST live inside the assets/ folder — use assets/scripts/ (e.g. assets/scripts/MyScript.ts). Do NOT place them in a root-level scripts/ folder.

Why: the scene's tsconfig.json only includes src/**/* and assets/**/*:

"include": ["src/**/*.ts", "src/**/*.tsx", "assets/**/*.ts", "assets/**/*.tsx"]

A file in a root-level scripts/ folder falls outside these globs, so the TypeScript type checker (and the build's type-check step) won't cover it. The Script component's path field is resolved relative to the project root, so it must match the real file location — e.g. "path": "assets/scripts/SitChair.ts".

Script structure

Every script is a single exported class with:

  • A constructor that receives configurable parameters (exposed in the Creator Hub UI).
  • An optional start() method, called once when the scene loads.
  • An optional update(dt: number) method, called every frame (~30 FPS; see "When scripts run" below for ordering — by default scripts update after all regular systems).

The first two constructor parameters must always be public src: string and public entity: Entity — do not remove or reorder them.

import { engine, Entity, Transform } from '@dcl/sdk/ecs'

export class MyScript {
  constructor(
    public src: string,
    public entity: Entity,
    public speed: number = 1
  ) {}

  start() {
    console.log('Script started on entity:', this.entity)
  }

  update(dt: number) {
    const transform = Transform.getMutable(this.entity)
    transform.rotation.y += this.speed * dt
  }
}

When scripts run — the priority field (IMPORTANT)

The Creator Hub Script component has a priority field (separate from constructor params — it's set in the component UI, not in your class). It defaults to 0, and this has non-obvious consequences:

  • Default priority 0 = scripts run LAST each frame. At build time @dcl/sdk-commands groups all scripts by their priority value and registers ONE engine system per group via engine.addSystem(updateLoop, Number(priority)). Because engine systems run highest-priority-first (regular systems use 100000, UI uses 100000), priority 0 runs after everything else.
  • All scripts sharing the same priority share ONE system callback and run sequentially inside it. A single heavy script's update() therefore delays every other script in the same priority group that frame.
  • To run a script's update() before regular systems, raise its priority (e.g. 1000000) in the Script component. This also gives it its own dedicated system. This is how you'd step a physics library (cannon.js) inside update(dt) ahead of systems that read the result — there is no separate physics loop.

Higher priority number = earlier execution — the OPPOSITE of the "priority 1 = first" assumption. See the scene-runtime skill's "System Execution Order & Priority" section for the underlying engine rule.

Constructor parameters

Parameters declared in the constructor are exposed in the Creator Hub UI and can be configured per-entity. Allowed types:

TypeEditor UI
stringtext field
numbernumber field
booleancheckbox
Entityentity picker (lets the user pick another entity from the scene)
ActionCallbackaction picker — see "ActionCallback parameters"
Slider<Min, Max, Step>slider plus a number box — see below

Both public and private parameters are exposed to Creator Hub. Use this.<paramName> to access values in your code.

Slider<Min, Max, Step> — a number edited with a slider

public speed: Slider<0, 10, 0.5> = 1
  • The runtime value is a plain number — the type is a pure alias (type Slider<Min, Max, Step = 1> = number), so nothing changes in your code or in serialization. It only tells the editor to render a slider.

  • Step defaults to 1 — Slider<0, 100> steps by 1.

  • An invalid declaration degrades to a plain number field (no error): missing bounds, min >= max, or step <= 0. Negative bounds and negative defaults are fine.

  • A default outside the range is clamped; if you later widen or narrow the bounds, the stored value is re-clamped into the new range on script reload.

  • Exported from @dcl/asset-packs, next to ActionCallback. Scenes cannot import it yet, so the Creator Hub's script template declares a local alias — copy this line into your script file:

    // A number edited with a slider in the Creator Hub UI: Slider<min, max, step>
    type Slider<Min extends number, Max extends number, Step extends number = 1> = number
    

    (~sdk/script-utils is its eventual home; until then the local alias is the supported form.)

Default values

Provide default values so the script works out of the box:

constructor(
  public src: string,
  public entity: Entity,
  public radius: number = 5,
  public label: string = 'Hello',
  public enabled: boolean = true,
) {}

Optional parameters

Use ? for parameters that may be left empty by the user:

constructor(
  public src: string,
  public entity: Entity,
  public targetEntity?: Entity,
  public message?: string,
) {}

Parameter tooltips

Add @param annotations in a JSDoc comment block directly before the constructor to show tooltips in the Creator Hub UI:

/**
 * @param startDate - The start date of the campaign in YYYY-MM-DD format
 * @param endDate - The end date of the campaign in YYYY-MM-DD format
 * @param wearableYOffset - How many meters above the ground the wearable should be displayed
 */
constructor(
  public src: string,
  public entity: Entity,
  public startDate?: string,
  public endDate?: string,
  public wearableYOffset: number = 0.5,
) {}

The layout field — how params are stored and resolved at runtime

The asset-packs::Script component value has the shape { value: [{ path: string, priority: number, layout?: string }] }. The layout field is a JSON string:

{"params":{"paramName":{"type":"string|number|boolean|action","value":<value>}}}

layout also carries actions (from @action methods) and events (from @event tags, see below), plus an error string when parsing failed. Only params feeds the constructor — but preserve the other keys whenever you rewrite the layout, or the editor features they drive disappear.

At runtime, @dcl/sdk-commands/dist/logic/runtime-script.js resolves params like this:

  1. JSON.parse(layout) to get a ScriptLayout object.
  2. Object.values(layout.params) extracts param values in insertion order.
  3. The values are spread positionally into the constructor: new ScriptClass(src, entity, ...params).

CRITICAL gotcha — params are positional, not named. The key insertion order in the layout JSON MUST match the constructor parameter order (after src and entity). You cannot skip earlier params to set later ones — every param from src/entity onward must be present in order.

Params with type: "action" have value: {entity: Entity, action: string} and are converted to ActionCallback functions via createActionCallback (which calls getActionEvents(entity).emit(action, {}) when invoked).

This is relevant for agents/MCP tools setting Script params programmatically via set_component on asset-packs::Script — the full ordered params object must be provided. (When the scene is open in the Creator Hub, prefer its MCP attach_script tool — write the script file, then call it with the path and it builds the component for you; see creator-hub-mcp.) Example layout for a script with 14 constructor params (after src/entity):

{"params":{"colorA":{"type":"string","value":"#ff33e6"},"colorB":{"type":"string","value":"#33e6ff"},"rate":{"type":"number","value":35},"lifetime":{"type":"number","value":2.5},"sizeMin":{"type":"number","value":0.3},"sizeMax":{"type":"number","value":0.6},"speedMin":{"type":"number","value":2.5},"speedMax":{"type":"number","value":4},"gravity":{"type":"number","value":-0.3},"spin":{"type":"number","value":0},"shape":{"type":"string","value":"sphere"},"shapeSize":{"type":"number","value":0.4},"maxParticles":{"type":"number","value":150},"startPlaying":{"type":"boolean","value":true}}}

Verified in: @dcl/sdk-commands/dist/logic/runtime-script.js — resolveScriptParams uses Object.values(params).map(...) and the result is spread as new ScriptClass(src, entity, ...params).

Referencing assets with this.src

If your script uses additional assets that are only loaded via code (sound files, textures, models, etc.), they won't be automatically included in the custom item folder. You must add those files manually.

Always use this.src to build the path to bundled asset files, because the actual file location may differ when the item is used in another scene:

import { AudioSource } from '@dcl/sdk/ecs'

start() {
  AudioSource.create(this.entity, {
    audioClipUrl: this.src + '/sounds/click.mp3',
    playing: false
  })
}

Referencing child entities

Do not pass entities that belong to the same custom item as Entity input parameters. Entity IDs are not stable across scenes — an entity ID that is valid in your development scene may not exist in a user's scene.

Instead, find child entities at runtime by iterating over the entity hierarchy and matching their Name component value with a substring match (e.g. .startsWith(...) or .includes(...)).

Best practice: match a substring of the child's name, not an exact name, and not a per-instance constructor parameter. When a user duplicates a smart item or drops multiple copies into a scene, the Creator Hub editor auto-numbers the duplicated child entities — e.g. "Sit Spot", "Sit Spot 2", "Sit Spot 3", … A substring match (name.includes('Sit Spot')) finds the matching child in every copy automatically, with zero per-instance configuration.

  • Exact-name match fails on every duplicate after the first, because their names are auto-suffixed.
  • A constructor string/Entity parameter for the child name forces the user to manually rename or rewire each copy, which defeats the purpose of a reusable scripted item.

The example below uses .startsWith('Needle') — a substring-style match — for exactly this reason.

import { engine, Entity, Transform, Name } from '@dcl/sdk/ecs'

export class ClapMeter {
  private needleEntities: Entity[] = []

  constructor(
    public src: string,
    public entity: Entity,
  ) {}

  start() {
    for (const [childEntity, transform] of engine.getEntitiesWith(Transform)) {
      if (transform.parent === this.entity) {
        const nameComponent = Name.getOrNull(childEntity)
        if (nameComponent && nameComponent.value.startsWith('Needle')) {
          this.needleEntities.push(childEntity)
        }
      }
    }
  }
}

This pattern keeps the script portable: as long as the child entities have names containing the expected substring, it works in any scene and across any number of duplicated copies.

Defining actions (@action)

If your script has functions that could be useful to call from other items in the scene, mark them by adding a JSDoc comment block (/** ... */) with an @action tag directly before the method. Then add an Action component to the entity and define a corresponding action. This lets other smart items (e.g. a button) pick and trigger this action.

CRITICAL: Use ONLY the @action JSDoc tag — NEVER decorator syntax (@action() above the method). The Creator Hub parser has no decorators plugin, so a decorator makes parsing fail and ALL params and actions silently disappear from the UI.

An optional description line before the @action tag becomes the action's description shown in the Creator Hub UI.

import { engine, Entity } from '@dcl/sdk/ecs'

export class TreasureChest {
  private isOpen = false

  constructor(
    public src: string,
    public entity: Entity,
  ) {}

  /**
   * Opens the chest
   * @action
   */
  open() {
    if (this.isOpen) return
    this.isOpen = true
    console.log('Chest opened!')
  }

  /**
   * Closes the chest
   * @action
   */
  close() {
    if (!this.isOpen) return
    this.isOpen = false
    console.log('Chest closed!')
  }
}

With the @action JSDoc tag, open and close become available in the Actions component dropdown and can be triggered by other smart items or scripts.

ActionCallback parameters

Use the ActionCallback type from ~sdk/script-utils to let users wire up editor-configured actions as callbacks on your script. The user can then assign any action (from any item) to that callback in the Creator Hub UI.

import { Entity } from '@dcl/sdk/ecs'
import type { ActionCallback } from '~sdk/script-utils'

export class Padlock {
  constructor(
    public src: string,
    public entity: Entity,
    public onUnlock: ActionCallback,
  ) {}

  /**
   * @action
   */
  solve() {
    this.onUnlock()
  }
}

Unwired ActionCallback params are undefined (sdk-commands fix 8da580d5)

An optional ActionCallback the user never assigns in the editor now resolves to undefined, so the obvious guard works:

constructor(
  public src: string,
  public entity: Entity,
  public onUnlock?: ActionCallback,   // ActionCallback | undefined
) {}

solve() {
  if (this.onUnlock) this.onUnlock()   // safe: no-op when unwired
}

Previously an unassigned param resolved to a truthy placeholder ({ entity: 0, action: '' } wrapped into a callable), so if (this.onUnlock) always passed and calling it logged a console.error. If you wrote a workaround around that — checking entity !== 0, or swallowing the error — remove it. On an older @dcl/sdk-commands the old behavior is still live, so a truthiness guard alone is not a safety net there.

Declare these params optional (?) when the behavior should work with nothing wired; a non-optional ActionCallback says the item is broken without one.

Calling other scripts from code

Use the runtime utilities in ~sdk/script-utils to call methods on other Script component instances:

import {
  callScriptMethod,
  getScriptInstance,
  getAllScriptInstances,
  getScriptInstancesByPath
} from '~sdk/script-utils'

callScriptMethod(entity, 'assets/scripts/Padlock.ts', 'solve', 123)

const instance = getScriptInstance(entity, 'assets/scripts/Padlock.ts')
const allOnEntity = getAllScriptInstances(entity)
const allByPath = getScriptInstancesByPath('assets/scripts/Padlock.ts')

Smart Items are now Script-based (creator-hub 658f4daf, PR #1354)

Most catalog Smart Items were migrated from the Actions/Triggers no-code graph to asset-packs::Script code with @action-tagged methods — the same mechanism this skill documents. What that means when you inspect or extend one:

  • A migrated item carries no asset-packs::Triggers. Do not look for a trigger graph; the behavior is in the .ts file the Script component points at (Door.ts, Seat.ts, Button.ts, Lever.ts, Bell.ts, Teleport.ts, Sign.ts, …).
  • The Actions that remain are call_script_method pointers that preserve the old public action names, so existing scenes that referenced "Open" / "Close" / "Open or Close" / "Sit Here" keep working. Their jsonPayload is {"scriptPath": "{assetPath}/Door.ts", "methodName": "open", "params": {}}.
  • The Script's params ARE the configuration UI — the inspector::Config panel was removed from the migrated items. It survives on the items that were not migrated: the lights, the screen/media family, camera, admin_toolkit, image, nft, text, and the health/combat family. (Do not state that Config was removed everywhere — it was not.)
  • "When X happens" hooks are optional ActionCallback params. Real names in the shipped items: onClick, onActivate / onDeactivate, onReachStart / onReachEnd (the two most common, on the moving-platform family), onRing (bell), and onOpen / onClose (the open/closed sign — not the doors). Wire them the way this skill's ActionCallback section describes.
  • State lives in a synced asset-packs::States component listed in core-schema::Sync-Components ({"componentIds": ["asset-packs::States"]}). E.g. a door creates States with value: ['Open','Closed'], defaultValue: 'Closed' in start(); a chair carries ['Free','Taken'] per sit spot. Read and write state through States, not through script instance fields, or it will not replicate.

Not migrated (still Actions/Triggers): click_area, audio_stream, the health/combat family (first_aid, healing_pad, health_bar, respawn_pad, robot, spikes, sword, barrel, wooden_wall), and camera (Actions + Config, no Triggers). video_player is [DEPRECATED] (moved to the deprecated category) — use one of the video screen items instead.

New items are written as Scripts from the start. locomotion_settings ("Locomotion Settings", utils, creator-hub cb097fff) is the current reference example and worth reading before writing your own: nine Slider<Min,Max,Step> params seeded with the SDK defaults and a @param tooltip each, a start() that writes a component onto engine.PlayerEntity (a global effect, not a per-entity one — the item has no in-world presence at all), two @action methods (apply re-writes the values, restoreDefaults calls deleteFrom), and an editor-only asset-packs::Placeholder cube so the thing can be selected in the viewport while being invisible in-world. It carries no Actions, no Triggers, no Config, and no States. See the player-avatar skill for what it configures.

world_teleport was removed and merged into teleport, which gained a world param. world wins when set: a non-empty world does a realm change, otherwise it teleports to x/y coordinates. Update any scene or instruction referencing world_teleport.

trigger_area is now a Script item too. The old Actions/Triggers trigger_area asset was deleted in creator-hub e43441b5; the script-based trigger_area_prompt (shown as "Trigger Area", id e9fa0eab-44de-4efe-af77-a71171a1a73f) is the only one left — the interim "Trigger Area (Script)" label is gone. Read TriggerArea.tsx in that item before writing a detector of your own; it is the reference for the @event pattern below. See add-interactivity for the TriggerArea component itself.

@event — declaring hooks for reaction scripts

A class (or start() function) JSDoc may carry @event <name> tags alongside @param and @action:

/**
 * @event enter
 * @event exit
 */
export class TriggerAreaDetector { /* … */ }
  • The inspector parses them (ScriptInspector/parser.ts → extractEvents) and stores them as an events: string[] key in the Script's layout JSON, next to params and actions. A non-empty events is what makes the inspector show a Reactions section, with one prompt button per event; clicking one opens the AI assistant with a natural-language sentence already seeded ("When a player enters, …"). Declare @event and the no-code reaction UI appears for free.
  • Tags are accepted on the export statement, the class declaration, or the constructor JSDoc — any of the three.
  • Names match [A-Za-z0-9_-]+; duplicates are collapsed.

The detector/reaction split this enables. The SDK keeps only one callback per (entity, event) pair, so a second script calling triggerAreaEventsSystem.onTriggerEnter on the same entity silently replaces the first. The shipped pattern avoids that: one script owns the SDK callbacks and exposes onEvent(name, fn); reaction scripts on the same entity find it with getAllScriptInstances(entity) from ~sdk/script-utils and subscribe. A reaction must never touch triggerAreaEventsSystem itself. In TriggerArea.tsx the detector also replays enter to a late subscriber for anything already inside, and exposes isInside() for "while inside" logic.

⚠️ When writing the layout JSON by hand, carry events through on every edit. Rebuilding the layout from params alone drops it and the Reactions section is wiped for good — exactly the bug fixed in creator-hub e43441b5 (editing the Trigger Area's shape used to destroy its prompt fields). Spread the existing layout and overwrite only the one param you are changing.

Behavior changes worth knowing: a seat frees itself when the sitter walks more than 1.5 m away; sit() picks the nearest free spot; with no spot free the item shows its takenMessage (default "Seat is taken"). The avatar is moved before the sitting emote plays — reversing that order cancels the emote.

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/script-components">View script-components on skillZs</a>