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, dropfontSize(e.g. 12) or setsizeon the button. Seereferences/npc-library.mdc"ButtonData fields". -
Opening dialogs on an entity not created via
npc.createrequiresaddDialog(entity)and a minimalnpcDataComponent.set(entity, ...)— see reference for the full setup. -
Speech bubbles need
createDialogBubble(entity)beforetalkBubble. Bubbles do not render question buttons; questions are HUD-only. -
createDialogWindow()crashes the dialog UI unless you also setnpcDataComponent. Symptom:"Cannot read properties of undefined (reading 'theme')"when the window opens. Why (verified againstdcl-npc-toolkit/dist):createDialogWindow(portrait, sound)only callsaddDialog(...)(setsnpcDialogComponent); it does NOT setnpcDataComponent.openDialogWindowthen setsactiveNPC, thenpcDialogComponentguard (isActiveNpcSet()) passes, andgetTheme()reachesnpcDataComponent.get(activeNPC).theme— undefined on a standalone window. NPCs built withcreate()/createNPChavenpcDataComponentset, so they never hit this. Fix — aftercreateDialogWindow, setnpcDataComponentwith 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 afaceUserNPC. Why (verified —dcl-npc-toolkit/dist/faceUserSystem.js):faceUserrewrites the NPC entity'sTransform.rotationevery frame (viaTrackUserFlag+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.defineComponentthrows"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 dynamicawait import('./client-setup')defers that registration past the seal point. So any module that imports the toolkit must be reached via a staticimportfromindex.ts— not loaded later viaawait 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.
idis required and must be unique per AvatarShape entity.- Set
name: ''(empty string) to suppress the name tag above the NPC's head. - Moving the
Transformposition causes the NPC to walk/run to the destination (it does not teleport). This mutation DOES take effect — unlike the read-only playerTransform. You can also drive movement withTween(Tween.Mode.Move) +TweenSequencefor scripted paths; the avatar plays its walk animation along the tween. For an NPC that continuously chases the player (or another moving target), useTween.setMoveContinuous— do NOT re-createsetMovetweens every frame, which causes visible jitter. See the animations-tweens skill ("PITFALL — a CONSTANTLY changing target") and the79,-4-tween-following-cubetest scene. - Use
expressionTriggerTimestampas a Lamport timestamp to replay the same emote: first play = 0, second play = 1, etc. - Clone the current player's look: read
getPlayer()and passuserData.wearablesanduserData.emotesstraight intoAvatarShape.create.getPlayer()may return null / empty emotes on the first frames — poll in a system untiluserData.emotes.length > 0before 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 byTween/TweenSequence; anAvatarAttachbox on the NPC's hand via itsid. - 4,21-avatar-shape-emotes — clone the local player onto an NPC using
getPlayer().wearables/.emotes, then cycle emotes viaexpressionTriggerId. - 4,22-avatar-shape-scene-emotes — play custom scene emotes on an NPC by setting
expressionTriggerIdto an_emote.glbpath.
These are AvatarShape-only scenes; they do not use dcl-npc-toolkit.