NPCs in Decentraland
Two approaches — choose based on what the NPC needs to do:
| Approach |
Use when |
NPC Toolkit (dcl-npc-toolkit) |
GLB model, needs dialogue, walking, state machine behavior |
| AvatarShape |
Needs to look like a Decentraland avatar (wearables, expressions) |
Approach 1 — NPC Toolkit (GLB-based)
The toolkit handles dialogue UI, movement along paths, animations, and interaction out of the box.
Install:
npm i dcl-npc-toolkit
Basic usage:
import * as npc from 'dcl-npc-toolkit'
import { Vector3, Quaternion } from '@dcl/sdk/math'
const dialogs: npc.Dialog[] = [{ text: 'Hello there!', isEndOfDialog: true }]
const npcEntity = npc.create(
{ position: Vector3.create(8, 0, 8), rotation: Quaternion.fromEulerDegrees(0, 180, 0) },
{
type: npc.NPCType.CUSTOM,
model: { src: 'models/guard.glb' },
idleAnim: 'Idle',
walkingAnim: 'Walk',
hoverText: 'Talk',
onlyExternalTrigger: false,
onActivate: () => {
// called when player activates the NPC
npc.talk(npcEntity, dialogs)
},
}
)
For full dialogue scripting, movement paths, state machines, and all config options, see {baseDir}/references/npc-library.mdc — it covers:
- Dialogue types (talk, button choices, NPC responses)
- Walking to positions and following paths
- State management (quest giver, guard, shop patterns)
- Multiplayer considerations
- Performance optimization
Gotchas (NPC Toolkit)
Button labels are visually truncated. Dialog button labels render with textWrap: 'nowrap' in a fixed-width slot (default font 16, slot ~217px scaled). Anything past ~15 characters is silently clipped — no ellipsis. Use short labels like "Yes", "No thanks", "Tell me more", "Decline". Avoid full sentences and trailing punctuation (e.g. "I'm not interested." renders as "I'm not interes"). To fit longer text, drop fontSize (e.g. 12) or set size on the button. See references/npc-library.mdc "ButtonData fields".
Opening dialogs on an entity not created via npc.create requires addDialog(entity) and a minimal npcDataComponent.set(entity, ...) — see reference for the full setup.
Speech bubbles need createDialogBubble(entity) before talkBubble. Bubbles do not render question buttons; questions are HUD-only.
createDialogWindow() crashes the dialog UI unless you also set npcDataComponent. Symptom: "Cannot read properties of undefined (reading 'theme')" when the window opens. Why (verified against dcl-npc-toolkit/dist): createDialogWindow(portrait, sound) only calls addDialog(...) (sets npcDialogComponent); it does NOT set npcDataComponent. openDialogWindow then sets activeNPC, the npcDialogComponent guard (isActiveNpcSet()) passes, and getTheme() reaches npcDataComponent.get(activeNPC).theme — undefined on a standalone window. NPCs built with create()/createNPC have npcDataComponent set, so they never hit this. Fix — after createDialogWindow, set npcDataComponent with a valid theme and the minimal fields:
import { createDialogWindow } from 'dcl-npc-toolkit'
import { npcDataComponent } from 'dcl-npc-toolkit/dist/npc'
import { lightTheme } from 'dcl-npc-toolkit/dist/ui'
const window = createDialogWindow(portrait, sound)
npcDataComponent.set(window, {
introduced: false, inCooldown: false, coolDownDuration: 5,
faceUser: undefined, walkingSpeed: 2, walkingAnim: undefined,
pathData: undefined, currentPathData: [], manualStop: false,
pathIndex: 0, state: 'standing', idleAnim: 'Idle', hasBubble: false,
turnSpeed: 2, theme: lightTheme, bubbleXOffset: 0, bubbleYOffset: 0,
lastPlayedAnim: 'Idle', volume: 0.5,
})
faceUser: true — do NOT parent a fixed-world object to a faceUser NPC. Why (verified — dcl-npc-toolkit/dist/faceUserSystem.js): faceUser rewrites the NPC entity's Transform.rotation every frame (via TrackUserFlag + faceUserSystem) to look at the player. Any child placed at a local offset inherits that rotation and orbits the NPC as the player moves, landing in unintended places (behind a door, occluded, unclickable). Fix: spawn such objects unparented at a computed world position (derive a stable world spot from a non-rotating reference like a door, plus the NPC's position). The same caveat applies to any entity whose Transform you rotate every frame.
dcl-npc-toolkit must be statically imported from the entry point. The toolkit calls engine.defineComponent(...) at module-load time. engine.defineComponent throws "Engine is already sealed. No components can be added at this stage" if it runs after the engine seals (verified — @dcl/ecs/dist/engine/index.js). A dynamic await import('./client-setup') defers that registration past the seal point. So any module that imports the toolkit must be reached via a static import from index.ts — not loaded later via await import(). In an authoritative-server scene, keep the client setup (which imports the toolkit) static and dynamically import only the server-only branch. See [[authoritative-server]].
Approach 2 — AvatarShape (Decentraland avatar look)
Create an NPC that looks like a Decentraland player avatar, dressed in any wearables.
import { engine, Transform, AvatarShape } from '@dcl/sdk/ecs'
import { Vector3 } from '@dcl/sdk/math'
const npc = engine.addEntity()
Transform.create(npc, { position: Vector3.create(8, 0, 8) })
AvatarShape.create(npc, {
id: 'npc-1', // unique identifier (required)
name: 'Guard', // display name shown above head
bodyShape: 'urn:decentraland:off-chain:base-avatars:BaseMale', // or BaseFemale
wearables: [
'urn:decentraland:off-chain:base-avatars:eyebrows_00',
'urn:decentraland:off-chain:base-avatars:mouth_00',
'urn:decentraland:off-chain:base-avatars:eyes_00',
'urn:decentraland:off-chain:base-avatars:blue_tshirt',
'urn:decentraland:off-chain:base-avatars:brown_pants',
'urn:decentraland:off-chain:base-avatars:classic_shoes',
'urn:decentraland:off-chain:base-avatars:short_hair',
],
hairColor: { r: 0.92, g: 0.76, b: 0.62 }, // RGB 0–1
skinColor: { r: 0.94, g: 0.85, b: 0.6 },
})
Notes:
- Always include eyebrows, mouth, and eyes wearables — the avatar won't render face features without them.
id is required and must be unique per AvatarShape entity.
- Set
name: '' (empty string) to suppress the name tag above the NPC's head.
- Moving the
Transform position causes the NPC to walk/run to the destination (it does not teleport). This mutation DOES take effect — unlike the read-only player Transform. You can also drive movement with Tween (Tween.Mode.Move) + TweenSequence for scripted paths; the avatar plays its walk animation along the tween. For an NPC that continuously chases the player (or another moving target), use Tween.setMoveContinuous — do NOT re-create setMove tweens every frame, which causes visible jitter. See the animations-tweens skill ("PITFALL — a CONSTANTLY changing target") and the 79,-4-tween-following-cube test scene.
- Use
expressionTriggerTimestamp as a Lamport timestamp to replay the same emote: first play = 0, second play = 1, etc.
- Clone the current player's look: read
getPlayer() and pass userData.wearables and userData.emotes straight into AvatarShape.create. getPlayer() may return null / empty emotes on the first frames — poll in a system until userData.emotes.length > 0 before spawning (verified pattern in test scene 4,21).
Playing expressions on an AvatarShape NPC
AvatarShape.getMutable(npc).expressionTriggerId = 'wave'
AvatarShape.getMutable(npc).expressionTriggerTimestamp = 1
expressionTriggerId accepts either a built-in emote name ('robot', 'wave', ...) OR a scene-emote .glb path (e.g. 'animations/Snowball_Throw_emote.glb') — the same _emote.glb-suffixed files used with triggerSceneEmote. This is how you play a custom emote on an NPC (there is no triggerSceneEmote equivalent for AvatarShape entities). To cycle through the player's own emotes, set expressionTriggerId = userData.emotes[i].
Note: replaying the same expressionTriggerId value back-to-back requires bumping expressionTriggerTimestamp; setting a different id each time replays without touching the timestamp.
Mannequin mode (show wearables without a body)
Useful for storefronts and wearable displays:
AvatarShape.create(mannequin, {
id: 'mannequin-1',
name: 'Display',
wearables: ['urn:decentraland:matic:collections-v2:0x...:0'],
showOnlyWearables: true,
})
For the full AvatarShape field reference, body shape URNs, and common base wearable URNs, see {baseDir}/../player-avatar/references/avatar-apis.md.
Adding interactivity to AvatarShape NPCs
AvatarShape entities are not clickable — they have no collider, so pointer events won't register on them directly. To let players interact with an AvatarShape NPC, use one of these approaches:
Option A — Add a MeshCollider for click interaction
Attach an invisible collider to the same entity so pointerEventsSystem can detect clicks (see add-interactivity skill):
import { MeshCollider, pointerEventsSystem, InputAction } from '@dcl/sdk/ecs'
// invisible cylinder collider roughly matching avatar size.
// Pointer clicks require the CL_POINTER collider layer; MeshCollider defaults to
// all layers (which includes CL_POINTER), so an explicit layer is optional here.
// If you set a layer, include ColliderLayer.CL_POINTER or clicks won't register.
MeshCollider.setCylinder(npc)
pointerEventsSystem.onPointerDown(
{ entity: npc, opts: { button: InputAction.IA_POINTER, hoverText: 'Talk' } },
() => {
console.log('Player clicked NPC')
}
)
Option B — Proximity-based interaction
Trigger the interaction when the player walks near the NPC instead of requiring a click:
import { engine, Transform } from '@dcl/sdk/ecs'
import { Vector3 } from '@dcl/sdk/math'
const INTERACT_DISTANCE = 4
engine.addSystem(() => {
const playerPos = Transform.get(engine.PlayerEntity).position
const npcPos = Transform.get(npc).position
const dist = Vector3.distance(playerPos, npcPos)
if (dist < INTERACT_DISTANCE) {
// start dialogue or other interaction
}
})
Example scenes
Engine-team test scenes for the AvatarShape approach (exercised against the real engine):
- 4,20-avatar-shape — spawn AvatarShape NPCs with base-avatar wearables and with on-chain collection wearable URNs;
name: '' to hide the name tag.
- 4,19-avatar-shape-movement — moving an AvatarShape by mutating its
Transform (walks to target) and by Tween/TweenSequence; an AvatarAttach box on the NPC's hand via its id.
- 4,21-avatar-shape-emotes — clone the local player onto an NPC using
getPlayer().wearables / .emotes, then cycle emotes via expressionTriggerId.
- 4,22-avatar-shape-scene-emotes — play custom scene emotes on an NPC by setting
expressionTriggerId to an _emote.glb path.
These are AvatarShape-only scenes; they do not use dcl-npc-toolkit.
1---2name: npcs3description: Create NPCs (non-player characters) in Decentraland scenes. Two approaches: the NPC Toolkit library (dcl-npc-toolkit) for GLB NPCs with dialogue, movement, and state machines; and AvatarShape for avatar-look NPCs in wearables. Use when the user wants to add an NPC, shopkeeper, or quest giver — any non-player entity with behavior or dialogue. For live player data (position, profile, wearables) see player-avatar instead.4---56# NPCs in Decentraland78Two approaches — choose based on what the NPC needs to do:910| Approach | Use when |11|---|---|12| **NPC Toolkit** (`dcl-npc-toolkit`) | GLB model, needs dialogue, walking, state machine behavior |13| **AvatarShape** | Needs to look like a Decentraland avatar (wearables, expressions) |1415---1617## Approach 1 — NPC Toolkit (GLB-based)1819The toolkit handles dialogue UI, movement along paths, animations, and interaction out of the box.2021**Install:**22```bash23npm i dcl-npc-toolkit24```2526**Basic usage:**27```typescript28import * as npc from 'dcl-npc-toolkit'29import { Vector3, Quaternion } from '@dcl/sdk/math'3031const dialogs: npc.Dialog[] = [{ text: 'Hello there!', isEndOfDialog: true }]3233const npcEntity = npc.create(34 { position: Vector3.create(8, 0, 8), rotation: Quaternion.fromEulerDegrees(0, 180, 0) },35 {36 type: npc.NPCType.CUSTOM,37 model: { src: 'models/guard.glb' },38 idleAnim: 'Idle',39 walkingAnim: 'Walk',40 hoverText: 'Talk',41 onlyExternalTrigger: false,42 onActivate: () => {43 // called when player activates the NPC44 npc.talk(npcEntity, dialogs)45 },46 }47)48```4950For full dialogue scripting, movement paths, state machines, and all config options, see **`{baseDir}/references/npc-library.mdc`** — it covers:51- Dialogue types (talk, button choices, NPC responses)52- Walking to positions and following paths53- State management (quest giver, guard, shop patterns)54- Multiplayer considerations55- Performance optimization5657### Gotchas (NPC Toolkit)5859- **Button labels are visually truncated.** Dialog button labels render with `textWrap: 'nowrap'` in a fixed-width slot (default font 16, slot ~217px scaled). Anything past ~15 characters is silently clipped — no ellipsis. Use short labels like `"Yes"`, `"No thanks"`, `"Tell me more"`, `"Decline"`. Avoid full sentences and trailing punctuation (e.g. `"I'm not interested."` renders as `"I'm not interes"`). To fit longer text, drop `fontSize` (e.g. 12) or set `size` on the button. See `references/npc-library.mdc` "ButtonData fields".60- Opening dialogs on an entity not created via `npc.create` requires `addDialog(entity)` and a minimal `npcDataComponent.set(entity, ...)` — see reference for the full setup.61- Speech bubbles need `createDialogBubble(entity)` before `talkBubble`. Bubbles do not render question buttons; questions are HUD-only.6263- **`createDialogWindow()` crashes the dialog UI unless you also set `npcDataComponent`.** Symptom: `"Cannot read properties of undefined (reading 'theme')"` when the window opens. Why (verified against `dcl-npc-toolkit/dist`): `createDialogWindow(portrait, sound)` only calls `addDialog(...)` (sets `npcDialogComponent`); it does NOT set `npcDataComponent`. `openDialogWindow` then sets `activeNPC`, the `npcDialogComponent` guard (`isActiveNpcSet()`) passes, and `getTheme()` reaches `npcDataComponent.get(activeNPC).theme` — undefined on a standalone window. NPCs built with `create()`/`createNPC` have `npcDataComponent` set, so they never hit this. **Fix — after `createDialogWindow`, set `npcDataComponent` with a valid theme and the minimal fields:**6465 ```typescript66 import { createDialogWindow } from 'dcl-npc-toolkit'67 import { npcDataComponent } from 'dcl-npc-toolkit/dist/npc'68 import { lightTheme } from 'dcl-npc-toolkit/dist/ui'6970 const window = createDialogWindow(portrait, sound)71 npcDataComponent.set(window, {72 introduced: false, inCooldown: false, coolDownDuration: 5,73 faceUser: undefined, walkingSpeed: 2, walkingAnim: undefined,74 pathData: undefined, currentPathData: [], manualStop: false,75 pathIndex: 0, state: 'standing', idleAnim: 'Idle', hasBubble: false,76 turnSpeed: 2, theme: lightTheme, bubbleXOffset: 0, bubbleYOffset: 0,77 lastPlayedAnim: 'Idle', volume: 0.5,78 })79 ```8081- **`faceUser: true` — do NOT parent a fixed-world object to a `faceUser` NPC.** Why (verified — `dcl-npc-toolkit/dist/faceUserSystem.js`): `faceUser` rewrites the NPC entity's `Transform.rotation` every frame (via `TrackUserFlag` + `faceUserSystem`) to look at the player. Any child placed at a local offset inherits that rotation and **orbits** the NPC as the player moves, landing in unintended places (behind a door, occluded, unclickable). Fix: spawn such objects **unparented at a computed world position** (derive a stable world spot from a non-rotating reference like a door, plus the NPC's position). The same caveat applies to any entity whose Transform you rotate every frame.8283- **dcl-npc-toolkit must be statically imported from the entry point.** The toolkit calls `engine.defineComponent(...)` at module-load time. `engine.defineComponent` throws `"Engine is already sealed. No components can be added at this stage"` if it runs after the engine seals (verified — `@dcl/ecs/dist/engine/index.js`). A dynamic `await import('./client-setup')` defers that registration past the seal point. So any module that imports the toolkit must be reached via a **static** `import` from `index.ts` — not loaded later via `await import()`. In an authoritative-server scene, keep the client setup (which imports the toolkit) static and dynamically import only the server-only branch. See [[authoritative-server]].8485---8687## Approach 2 — AvatarShape (Decentraland avatar look)8889Create an NPC that looks like a Decentraland player avatar, dressed in any wearables.9091```typescript92import { engine, Transform, AvatarShape } from '@dcl/sdk/ecs'93import { Vector3 } from '@dcl/sdk/math'9495const npc = engine.addEntity()96Transform.create(npc, { position: Vector3.create(8, 0, 8) })9798AvatarShape.create(npc, {99 id: 'npc-1', // unique identifier (required)100 name: 'Guard', // display name shown above head101 bodyShape: 'urn:decentraland:off-chain:base-avatars:BaseMale', // or BaseFemale102 wearables: [103 'urn:decentraland:off-chain:base-avatars:eyebrows_00',104 'urn:decentraland:off-chain:base-avatars:mouth_00',105 'urn:decentraland:off-chain:base-avatars:eyes_00',106 'urn:decentraland:off-chain:base-avatars:blue_tshirt',107 'urn:decentraland:off-chain:base-avatars:brown_pants',108 'urn:decentraland:off-chain:base-avatars:classic_shoes',109 'urn:decentraland:off-chain:base-avatars:short_hair',110 ],111 hairColor: { r: 0.92, g: 0.76, b: 0.62 }, // RGB 0–1112 skinColor: { r: 0.94, g: 0.85, b: 0.6 },113})114```115116**Notes:**117- Always include eyebrows, mouth, and eyes wearables — the avatar won't render face features without them.118- `id` is required and must be unique per AvatarShape entity.119- Set `name: ''` (empty string) to suppress the name tag above the NPC's head.120- Moving the `Transform` position causes the NPC to walk/run to the destination (it does not teleport). This mutation DOES take effect — unlike the read-only player `Transform`. You can also drive movement with `Tween` (`Tween.Mode.Move`) + `TweenSequence` for scripted paths; the avatar plays its walk animation along the tween. For an NPC that continuously chases the player (or another moving target), use `Tween.setMoveContinuous` — do NOT re-create `setMove` tweens every frame, which causes visible jitter. See the **animations-tweens** skill ("PITFALL — a CONSTANTLY changing target") and the [`79,-4-tween-following-cube`](https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/79,-4-tween-following-cube) test scene.121- Use `expressionTriggerTimestamp` as a Lamport timestamp to replay the same emote: first play = 0, second play = 1, etc.122- **Clone the current player's look:** read `getPlayer()` and pass `userData.wearables` and `userData.emotes` straight into `AvatarShape.create`. `getPlayer()` may return null / empty emotes on the first frames — poll in a system until `userData.emotes.length > 0` before spawning (verified pattern in test scene 4,21).123124### Playing expressions on an AvatarShape NPC125126```typescript127AvatarShape.getMutable(npc).expressionTriggerId = 'wave'128AvatarShape.getMutable(npc).expressionTriggerTimestamp = 1129```130131`expressionTriggerId` accepts either a **built-in emote name** (`'robot'`, `'wave'`, ...) OR a **scene-emote `.glb` path** (e.g. `'animations/Snowball_Throw_emote.glb'`) — the same `_emote.glb`-suffixed files used with `triggerSceneEmote`. This is how you play a custom emote on an NPC (there is no `triggerSceneEmote` equivalent for AvatarShape entities). To cycle through the player's own emotes, set `expressionTriggerId = userData.emotes[i]`.132133Note: replaying the **same** `expressionTriggerId` value back-to-back requires bumping `expressionTriggerTimestamp`; setting a **different** id each time replays without touching the timestamp.134135### Mannequin mode (show wearables without a body)136137Useful for storefronts and wearable displays:138139```typescript140AvatarShape.create(mannequin, {141 id: 'mannequin-1',142 name: 'Display',143 wearables: ['urn:decentraland:matic:collections-v2:0x...:0'],144 showOnlyWearables: true,145})146```147148For the full `AvatarShape` field reference, body shape URNs, and common base wearable URNs, see **`{baseDir}/../player-avatar/references/avatar-apis.md`**.149150---151152## Adding interactivity to AvatarShape NPCs153154AvatarShape entities are **not clickable** — they have no collider, so pointer events won't register on them directly. To let players interact with an AvatarShape NPC, use one of these approaches:155156### Option A — Add a MeshCollider for click interaction157158Attach an invisible collider to the same entity so `pointerEventsSystem` can detect clicks (see **add-interactivity** skill):159160```typescript161import { MeshCollider, pointerEventsSystem, InputAction } from '@dcl/sdk/ecs'162163// invisible cylinder collider roughly matching avatar size.164// Pointer clicks require the CL_POINTER collider layer; MeshCollider defaults to165// all layers (which includes CL_POINTER), so an explicit layer is optional here.166// If you set a layer, include ColliderLayer.CL_POINTER or clicks won't register.167MeshCollider.setCylinder(npc)168169pointerEventsSystem.onPointerDown(170 { entity: npc, opts: { button: InputAction.IA_POINTER, hoverText: 'Talk' } },171 () => {172 console.log('Player clicked NPC')173 }174)175```176177### Option B — Proximity-based interaction178179Trigger the interaction when the player walks near the NPC instead of requiring a click:180181```typescript182import { engine, Transform } from '@dcl/sdk/ecs'183import { Vector3 } from '@dcl/sdk/math'184185const INTERACT_DISTANCE = 4186187engine.addSystem(() => {188 const playerPos = Transform.get(engine.PlayerEntity).position189 const npcPos = Transform.get(npc).position190 const dist = Vector3.distance(playerPos, npcPos)191 if (dist < INTERACT_DISTANCE) {192 // start dialogue or other interaction193 }194})195```196197---198199## Example scenes200201Engine-team test scenes for the `AvatarShape` approach (exercised against the real engine):202203- [4,20-avatar-shape](https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/4,20-avatar-shape) — spawn AvatarShape NPCs with base-avatar wearables and with on-chain collection wearable URNs; `name: ''` to hide the name tag.204- [4,19-avatar-shape-movement](https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/4,19-avatar-shape-movement) — moving an AvatarShape by mutating its `Transform` (walks to target) and by `Tween`/`TweenSequence`; an `AvatarAttach` box on the NPC's hand via its `id`.205- [4,21-avatar-shape-emotes](https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/4,21-avatar-shape-emotes) — clone the local player onto an NPC using `getPlayer().wearables` / `.emotes`, then cycle emotes via `expressionTriggerId`.206- [4,22-avatar-shape-scene-emotes](https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/4,22-avatar-shape-scene-emotes) — play custom scene emotes on an NPC by setting `expressionTriggerId` to an `_emote.glb` path.207208These are `AvatarShape`-only scenes; they do not use `dcl-npc-toolkit`.