decentraland/sdk-skills

player-avatar

The live player in a Decentraland scene.

Ver código-fonte

Como usar

  1. Copie o comando e verifique o pacote antes de instalar no seu ambiente de agente.
  2. Consulte no original as ferramentas, credenciais e ambientes necessários.
  3. Descreva os dados de entrada e a entrega esperada; confira a saída com as fontes.
npx skills add https://github.com/decentraland/sdk-skills --skill player-avatar

Antes de começar

  • A instalação não inclui assinatura de modelo, franquia API ou acesso aos seus arquivos.
  • Estrelas e instalações indicam atividade, não um teste independente de qualidade ou segurança.
Documento original do Skill

Renderizado do repositório de origem, preservando títulos, exemplos, código, tabelas, links e imagens.

Player and Avatar System in Decentraland

CRITICAL: The player Transform is READ-ONLY from scene code

Transform on engine.PlayerEntity is engine-controlled. Mutations from scene code are silently ignored — your code compiles, runs, no error is thrown, and nothing moves in-world. This is the most common bug when trying to lift, push, knock back, float, or teleport the player.

typescript
// WRONG — compiles cleanly, runs, does NOTHING in-world
const t = Transform.getMutable(engine.PlayerEntity)
t.position.y += 0.1                      // ignored
t.position = Vector3.create(8, 0, 8)     // ignored
Transform.createOrReplace(engine.PlayerEntity, { ... }) // ignored

Symptom to recognize: TypeScript accepts the code, the system ticks, no console error, but the avatar never moves. If you wrote Transform...PlayerEntity and expected motion, this is your bug.

Correct API by intent:

GoalUseSkill
Instant teleport / smooth slide to a pointmovePlayerTo from ~system/RestrictedActionsthis skill, see below
Lift / float / launch / jump pad / knockback / push / wind / repulsionPhysics.* from @dcl/sdk/ecsplayer-physics
Restrict / freeze movementInputModifier on engine.PlayerEntityadvanced-input
Change run speed / jump heightAvatarLocomotionSettings on engine.PlayerEntitythis skill, see below

Transform.get(engine.PlayerEntity) is valid for reading position and rotation only.

Player Position and Movement (Reading)

Access the player's position via the reserved engine.PlayerEntity:

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

function trackPlayer() {
	if (!Transform.has(engine.PlayerEntity)) return

	const playerTransform = Transform.get(engine.PlayerEntity)
	console.log('Player position:', playerTransform.position)
	console.log('Player rotation:', playerTransform.rotation)
}

engine.addSystem(trackPlayer)

Always check Transform.has(engine.PlayerEntity) before reading player data — it may not be ready on the first frame.

Player speed — average over a window, never per frame

The player `Transform` does not advance every scene tick. A per-frame speed computed as Vector3.distance(pos, lastPos) / dt therefore alternates between 0 on ticks with no update and roughly 2x the real speed on the next one, and any "speed above X held for N seconds" test never passes. Observed in-world (SDK 7.30.1, 2026-10-12): a ~9 m/s sprint never satisfied a per-frame speed > 7.5 held 0.5 s; the same threshold over 0.5 s windows triggered on the first try.

Accumulate instead, and reset the window when it closes:

typescript
let winDist = 0, winTime = 0
let last = Vector3.Zero()

function speedSystem(dt: number) {
	if (!Transform.has(engine.PlayerEntity)) return
	const pos = Transform.get(engine.PlayerEntity).position
	const step = Vector3.distance(pos, last)
	last = pos
	if (step > 3) return          // teleport / respawn — discard, don't accumulate
	winDist += step
	winTime += dt
	if (winTime < 0.5) return     // window must be >= ~0.5 s to be stable
	const speed = winDist / winTime
	winDist = 0; winTime = 0
	// ... compare `speed` against your threshold here
}

Skip any single delta over ~3 m: that is a teleport or a respawn, not movement, and it spikes the average.

Distance-Based Logic

typescript
import { Vector3 } from '@dcl/sdk/math'

function proximityCheck() {
	const playerPos = Transform.get(engine.PlayerEntity).position
	const npcPos = Transform.get(npcEntity).position
	const distance = Vector3.distance(playerPos, npcPos)

	if (distance < 5) {
		console.log('Player is near the NPC')
	}
}

engine.addSystem(proximityCheck)

GOTCHA: remote players' Transforms are in WORLD coordinates

Transform.get(engine.PlayerEntity).position for the local player is scene-local (relative to the scene's base parcel). Another player's entity — the one you get from engine.getEntitiesWith(PlayerIdentityData) — reports its position in world coordinates. Mixing them silently puts remote players hundreds of metres away.

Convert by subtracting the base parcel times 16:

typescript
import { Vector3 } from '@dcl/sdk/math'

// scene.json base parcel 33,20
const SCENE_ORIGIN = Vector3.create(33 * 16, 0, 20 * 16)
const sceneLocal = Vector3.subtract(worldPosition, SCENE_ORIGIN)

33,20-spectate-mode applies the subtraction conditionally — only when the position falls outside the scene's AABB — which is how it handles the local and remote players through one code path without needing to know which is which. If you always know the entity is a remote player, subtract unconditionally.

Read the base parcel from scene.json rather than hardcoding it, or the conversion breaks the moment the scene is redeployed elsewhere.

Player Profile Data

Get the player's name, wallet address, and guest status:

typescript
import { getPlayer } from '@dcl/sdk/src/players'

function main() {
	const player = getPlayer()
	if (player) {
		console.log('Name:', player.name)
		console.log('User ID:', player.userId)
		console.log('Is guest:', player.isGuest)
	}
}
  • userId — the player's Ethereum wallet address (or guest ID)
  • isGuest — true if the player hasn't connected a wallet

Check isGuest before attempting any wallet-dependent feature (guests have no on-chain identity).

Mobile, other players' positions: getPlayer({ userId }).position, onEnterScene and items attached to other players report scene-relative coordinates on mobile — same frame as the local player's — only since mobile app v1.15.0 (docs commit 5f212e3, Oct 2026). On an older mobile build those values are in a different frame, so proximity checks and leaderboards keyed on another player's position mis-fire there. Anything safety-critical should be derived from the local player's own position on each client rather than from a remote one.

Profile Data for Off-Scene Users (Catalyst)

getPlayer(userId) only returns data for users currently connected to this scene. For any other address (parcel owner, NFT holder, leaderboard entry, off-scene claimant), fetch from the catalyst:

GET https://peer.decentraland.org/lambdas/profile/<wallet-address>
  • Always use peer.decentraland.org — it is the canonical catalyst regardless of realm/world. Worlds servers do NOT expose /lambdas, so do not blindly read realmInfo.baseUrl.
  • Response shape: json.avatars[0].avatar.{ bodyShape, wearables, eyes:{color}, hair:{color}, skin:{color} } (NOT the json[0].metadata.avatars... shape from older docs).
  • Unknown address returns { avatars: [], timestamp: 0 } — handle the empty array.
  • Colors come as { r, g, b, a } floats in [0,1]. Build a Color3 and pass it directly to AvatarShape.skinColor / hairColor / eyeColor — these fields take a raw Color3, NOT { color: Color3 } (wrapping causes TS2322).

AvatarShape.create({ id: address }) with only an id does NOT auto-fetch wearables — the avatar renders undressed unless you supply bodyShape, wearables, and the color fields explicitly.

Which API to use:

  • Local or in-scene player → getPlayer(userId) (sync, includes wearables/emotes).
  • Off-scene address → fetchAvatarFromCatalyst(address) (async HTTP).

For the full helper (fetchAvatarFromCatalyst), end-to-end usage example, and gotchas, see {baseDir}/references/catalyst-profile-fetch.md.

Avatar Attachments

Attach 3D objects to a player's avatar:

typescript
import {
	engine,
	Transform,
	GltfContainer,
	AvatarAttach,
	AvatarAnchorPointType,
} from '@dcl/sdk/ecs'

const hat = engine.addEntity()
GltfContainer.create(hat, { src: 'models/hat.glb' })
Transform.create(hat, {})

// Attach to the local player's avatar
AvatarAttach.create(hat, {
	anchorPointId: AvatarAnchorPointType.AAPT_NAME_TAG,
})

AvatarAttach requires the target player to be in the same scene — attachments disappear when the player leaves.

Before picking `AvatarAttach`, decide whether the item is cosmetic or aim-critical. Bone anchors inherit avatar skeleton animation (idle bob, walk cycle, gesture) — great for hats/backpacks/halos, bad for held weapons, aiming reticles, or anything where relative position must stay stable. See Held items vs cosmetic items below.

Held items vs cosmetic items — AvatarAttach vs parenting to engine.CameraEntity / engine.PlayerEntity

SDK7 gives you three distinct mechanisms for "an entity that follows the player". They are not interchangeable — picking the wrong one is the single most common mistake when porting "held item" patterns from SDK6, and the most common subtle failure is parenting an aim-sensitive item (gun, reticle, flashlight) to engine.PlayerEntity and discovering the item does not track camera pitch when the player looks up or down.

Default for any aim-sensitive held item: `Transform.parent = engine.CameraEntity`. Use engine.PlayerEntity only when you specifically want yaw-only / no-pitch behavior (a body-fixed item the player carries but never aims with).

GoalUseTracksReason
Aim-sensitive held item — gun, aiming reticle, flashlight, anything pointed by looking around. Recommended default for held gameplay items.Transform.parent = engine.CameraEntity (plus local position offset for "in front of and below" the camera)Camera yaw + pitchFollows the camera's full transform, so the item points where the player is looking — including up/down. This is the SDK7 analogue of SDK6's Attachable.FIRST_PERSON_CAMERA. Aim stable (no animation jitter).
Yaw-only / body-fixed item — a held shield the player doesn't aim, a static torch, a fixed-position carry item that should stay level regardless of where the player looks.Transform.parent = engine.PlayerEntity (plus local offset for hand-height / forward distance)Player root: feet position + body yaw only (no pitch)Follows the player's root transform. Stable (no animation), but stays level when the player looks up/down — wrong default for guns/aim items, correct for items meant to ride the body orientation only.
Cosmetic item — hat, halo, backpack, name plate, glow effect, torch visible to other players riding the avatar.AvatarAttach with an anchorPointId (e.g. AAPT_HEAD, AAPT_SPINE, AAPT_LEFT_HAND)The actual animated boneItem moves naturally with idle bob, walk cycle, and gestures — visually correct for cosmetics attached to the body. Not for aim — animation jitter makes aim-sensitive items unusable.

Why `engine.CameraEntity` is the right default for aim-sensitive items: engine.PlayerEntity only tracks the player's root (foot position + body yaw). Body yaw is NOT camera pitch — when the player tilts the camera up to aim at a flying target, the player root rotation does not change, so a gun parented to PlayerEntity stays flat and the muzzle doesn't track the look direction. Parenting to engine.CameraEntity inherits both yaw and pitch, so the gun aims where the camera looks. This matches the SDK6 Attachable.FIRST_PERSON_CAMERA behavior creators expect when porting.

Why bone anchors break aim: anchor points like AAPT_RIGHT_HAND, AAPT_SPINE, AAPT_HEAD are positions on the animated avatar skeleton. Every frame the engine pulls the bone's current world transform — which includes the procedural idle bob and any active animation clip. An entity parented there inherits all of that motion. For a weapon, this reads as jitter and makes aiming feel uncontrollable.

Example — gun held in first person (aim follows camera pitch)

typescript
import { engine, Transform, GltfContainer, CameraModeArea, CameraType } from '@dcl/sdk/ecs'
import { Vector3, Quaternion } from '@dcl/sdk/math'

const gun = engine.addEntity()
GltfContainer.create(gun, { src: 'assets/Models/blaster.glb' })
Transform.create(gun, {
	parent: engine.CameraEntity,           // gun follows camera (yaw + pitch) — aim tracks where you look
	position: Vector3.create(0.25, -0.2, 0.5), // right, down, forward of camera
	rotation: Quaternion.fromEulerDegrees(0, 0, 0),
	scale: Vector3.One(),
})

If the user does NOT want the item to track pitch (e.g. a held torch that should stay level, not point up when looking up), swap engine.CameraEntity for engine.PlayerEntity. Do not pick `PlayerEntity` for a gun — the result is a flat-pointing weapon that ignores look direction.

Pair with `CameraModeArea` or a forced camera mode when equipping a held gun, so the player is in first-person while aiming. See [[camera-control]].

Anti-pattern (what NOT to do for a held weapon)

typescript
// WRONG — gun jitters with every idle/walk/gesture animation frame
AvatarAttach.create(gun, {
	anchorPointId: AvatarAnchorPointType.AAPT_RIGHT_HAND,
})

This looks like the right SDK7 way to "put a gun in the avatar's hand" because the API name reads that way — but the hand bone is animated, so the gun is unaimable in practice. Use parenting instead.

typescript
// SUBTLY WRONG for a gun — looks correct in hip-fire, fails the moment the player aims up
Transform.create(gun, { parent: engine.PlayerEntity, position: ... })

engine.PlayerEntity inherits body yaw but NOT camera pitch. The gun stays flat when the player looks up to aim at a high target. Use engine.CameraEntity instead for any aim-sensitive item. PlayerEntity is correct only for body-fixed items that should stay level regardless of where the camera points (e.g. a carried lantern, a non-aimed shield).

SDK6 porting note

In SDK7 you have a choice that didn't exist in SDK6. SDK6's Attachable.FIRST_PERSON_CAMERA / Attachable.AVATAR mapped to coarse follow modes only. If you are porting a held item from an SDK6 scene that used Attachable.FIRST_PERSON_CAMERA, the SDK7 equivalent is parenting to `engine.CameraEntity` (NOT AvatarAttach to a hand anchor, and NOT engine.PlayerEntity — PlayerEntity loses camera pitch). See [[migrate-sdk6-to-sdk7]].

Anchor Points

typescript
AvatarAnchorPointType.AAPT_NAME_TAG // Above the head
AvatarAnchorPointType.AAPT_RIGHT_HAND // Right hand
AvatarAnchorPointType.AAPT_LEFT_HAND // Left hand
AvatarAnchorPointType.AAPT_POSITION // [DEPRECATED] Avatar root position — protocol recommends parenting to `engine.PlayerEntity` (body-fixed) or `engine.CameraEntity` (aim-sensitive) instead
AvatarAnchorPointType.AAPT_HEAD
AvatarAnchorPointType.AAPT_NECK
AvatarAnchorPointType.AAPT_SPINE
AvatarAnchorPointType.AAPT_SPINE1
AvatarAnchorPointType.AAPT_SPINE2
AvatarAnchorPointType.AAPT_HIP
AvatarAnchorPointType.AAPT_LEFT_SHOULDER
AvatarAnchorPointType.AAPT_LEFT_ARM
AvatarAnchorPointType.AAPT_LEFT_FOREARM
AvatarAnchorPointType.AAPT_LEFT_HAND_INDEX
AvatarAnchorPointType.AAPT_RIGHT_SHOULDER
AvatarAnchorPointType.AAPT_RIGHT_ARM
AvatarAnchorPointType.AAPT_RIGHT_FOREARM
AvatarAnchorPointType.AAPT_RIGHT_HAND_INDEX
AvatarAnchorPointType.AAPT_LEFT_UP_LEG
AvatarAnchorPointType.AAPT_LEFT_LEG
AvatarAnchorPointType.AAPT_LEFT_FOOT
AvatarAnchorPointType.AAPT_LEFT_TOE_BASE
AvatarAnchorPointType.AAPT_RIGHT_UP_LEG
AvatarAnchorPointType.AAPT_RIGHT_LEG
AvatarAnchorPointType.AAPT_RIGHT_FOOT
AvatarAnchorPointType.AAPT_RIGHT_TOE_BASE
AvatarAnchorPointType.AAPT_NAME_TAG

Anchor points inherit bone animation. Bone-targeted anchors (AAPT_RIGHT_HAND, AAPT_SPINE, AAPT_HEAD, etc.) follow the animated skeleton — idle bob, walk cycle, and gesture animations all propagate to the attached entity. This is correct for cosmetic items (hats, halos, backpacks) and wrong for gameplay items where aim stability matters (guns, reticles). For aim-sensitive items parent to engine.CameraEntity (yaw + pitch — the default for guns/reticles/flashlights); for yaw-only body-fixed items parent to engine.PlayerEntity. See "Held items vs cosmetic items" above.

Attach to a Specific Player

typescript
AvatarAttach.create(hat, {
	avatarId: '0x123...abc', // Target player's wallet address
	anchorPointId: AvatarAnchorPointType.AAPT_RIGHT_HAND,
})

avatarId is the target player's wallet address. To attach to every player in the scene (including remote ones), iterate engine.getEntitiesWith(PlayerIdentityData) and read player.address for each — guard with a marker component so you attach only once per player:

typescript
import { PlayerIdentityData } from '@dcl/sdk/ecs'

engine.addSystem(() => {
	for (const [entity, player] of engine.getEntitiesWith(PlayerIdentityData)) {
		// player.address is the wallet address to pass as avatarId
	}
})

To attach the local player's own held item, get the address from getPlayer() / await getPlayer() (.userId). Omitting avatarId attaches to the local player. For multiplayer visibility of a held/attached item, sync the anchor entity's AvatarAttach component (see [[multiplayer-sync]]).

Triggering Emotes

Default Emotes

typescript
import { triggerEmote } from '~system/RestrictedActions'

// Play a built-in emote
triggerEmote({ predefinedEmote: 'robot' })
triggerEmote({ predefinedEmote: 'wave' })
triggerEmote({ predefinedEmote: 'clap' })

Custom Scene Emotes

Need a clip? Before authoring one, check {baseDir}/../avatar-animations/references/animation-catalog.md: animated previews of every built-in emote, and 45 ready-made _emote.glb files from the Creator Hub's Avatar Animations pack (sitting on stools/puffs/bean bags, chess, fishing, carry, drink, dance…) with their download and the exact src path to play them.

⚠️ CRITICAL FILE NAMING REQUIREMENT: The emote .glb file MUST end with _emote.glb (case-insensitive). This is not optional and not just a convention — the runtime rejects files that don't match this suffix. Why this matters: Scenes with incorrectly named emote files often work fine in `npm run start` preview but silently fail in production once deployed. Preview is more permissive; the deployed runtime is strict. Always rename the file on disk (e.g. SnowballThrow.glb → SnowballThrow_emote.glb) before deploying. Valid: wave_emote.glb, Snowball_Throw_emote.glb, dance_EMOTE.GLB Invalid: wave.glb, emote_wave.glb, wave_emote_v2.glb
typescript
import { triggerSceneEmote } from '~system/RestrictedActions'

// File MUST end with _emote.glb — rename it on disk if it doesn't
// src is the path from the PROJECT ROOT, exactly as on disk (usually under assets/)
triggerSceneEmote({
	src: 'assets/animations/Snowball_Throw_emote.glb',
	loop: false,
})

Notes:

  • `src` is the path relative to the project root, exactly as the file sits on disk — an exact content lookup (SceneContent.TryGetHash(src)), with no search by file name or _emote suffix. A wrong path makes triggerSceneEmote resolve false with no error logged, and nothing plays. A file at assets/animations/sit_emote.glb needs 'assets/animations/sit_emote.glb'; a bare 'animations/...' only works if that folder really is at the project root. Same rule for AvatarShape.expressionTriggerId — see npcs.
  • Emotes play only while the player is standing still — walking or jumping interrupts them
  • If you don't want a player to interrupt an emote, use the InputModifier component to freeze the player for the duration of the emote
  • Both triggerEmote and triggerSceneEmote require the scene to declare the ALLOW_TO_TRIGGER_AVATAR_EMOTE permission in scene.json requiredPermissions.
  • Both accept an optional mask (upper-body-only animation) — see "Emote masks" below.
  • To create the .glb (pose the official avatar rig in Blender, export, verify), see the avatar-animations skill.

Stopping an emote

stopEmote({}) from ~system/RestrictedActions stops the local player's currently playing emote (built-in or scene emote). Useful to end a looping scene emote (triggerSceneEmote({ src, loop: true })) on demand — e.g. a "pick up / put down" toggle.

typescript
import { stopEmote } from '~system/RestrictedActions'

stopEmote({}).then(({ success }) => {
	// `success` is a plain boolean (the RPC returns `SuccessResponse`).
})

If typeof success reads 'object' rather than 'boolean', the runtime handed the scene an unresolved promise instead of the value — a client/SDK defect, not a scene bug. Verified in the 80,-6-explorer-ui-events test scene, which exists partly to catch it.

Mobile: stopEmote is supported from mobile app v1.15.0 (docs commit 5f212e3). The same release also made scene-started upper-body emotes pause when the player leaves the scene, resume on return, and stop on scene unload — and made them read as upper-body to other mobile players. On older mobile builds a looping scene emote could not be stopped from the scene, so do not design a mechanic whose only exit is stopEmote; give the player an action that also restores the pose some other way.

Detecting when an emote finishes

Every emote lifecycle event is appended to the AvatarEmoteCommand grow-only set on the player entity, with a state field (EmoteState enum). Works for scene-triggered emotes (triggerEmote/triggerSceneEmote), emotes the player plays via the emote wheel, AND other players' emotes (pass their entity instead of engine.PlayerEntity).

typescript
import { AvatarEmoteCommand, EmoteState } from '@dcl/sdk/ecs'

AvatarEmoteCommand.onChange(engine.PlayerEntity, (cmd) => {
	if (!cmd) return
	switch (cmd.state ?? EmoteState.ES_STARTED) {
		case EmoteState.ES_STARTED:     // emote started (also the value when `state` is absent)
			break
		case EmoteState.ES_FINISHED:    // non-looping emote played to its natural end
			break
		case EmoteState.ES_INTERRUPTED: // cut short: movement/jump, teleport, another emote, stopEmote(), or scene exit
			break
	}
})
  • state is an optional protocol field: always default it to ES_STARTED (cmd.state ?? EmoteState.ES_STARTED) rather than assuming it is present.
  • Masked (partial-body) emotes on the local player report no lifecycle events — known limitation. A scene that needs to know when a masked clip ends must time it itself from the clip duration.
  • Verified against protocol avatar_emote_command.proto (commit 215d09c, field 5, optional) and js-sdk-toolchain (commit f858f905).

Emote masks (upper-body only)

Full-body emotes are interrupted when the player walks or jumps — the default locomotion animations take over. AvatarMask.AM_UPPER_BODY limits the animation to the waist up, leaving the legs controlled by locomotion. This means the player can keep walking, running, and jumping while the upper body plays your animation. Use cases: carrying a crate, holding a torch, juggling, cheering while running.

triggerEmote and triggerSceneEmote both accept an optional mask (enum AvatarMask, imported from @dcl/sdk/ecs) that limits which bones the animation drives.

typescript
import { AvatarMask } from '@dcl/sdk/ecs'
import { triggerSceneEmote } from '~system/RestrictedActions'

triggerSceneEmote({ src: 'animations/Carry_emote.glb', loop: true, mask: AvatarMask.AM_UPPER_BODY })
  • Only value: AvatarMask.AM_UPPER_BODY (= 0). Omitting mask plays the full-body animation (the default) — there is no AM_FULL_BODY value in the enum.
  • mask applies to triggerEmote and triggerSceneEmote only. stopEmote({}) takes no arguments (StopEmoteRequest is empty).
  • Loop + mask interaction: loop: false with mask: AM_UPPER_BODY plays the upper-body animation exactly once, then returns the upper body to locomotion. loop: true with the mask repeats until stopEmote({}) is called. The loop flag is respected regardless of the mask. Verified against sdk7-test-scenes 88,-13-avatar-masks and 80,-1-scene-emotes (commit 1c0f394).
  • Mobile support: Avatar Masks (upper-body-only emotes) are available on mobile since v1.13.0 — no longer a parity gap — and since v1.15.0 a masked emote is also seen as upper-body by other mobile players rather than as full-body. Older mobile clients play masked emotes as full-body, so keep the full-body fallback visually acceptable. Still open: emotes arriving from other clients (scene emotes from Bevy-Web players, Unity players' emotes over LiveKit) may not play on mobile at all. Verified against docs commit 5f212e3 (Oct 2026, mobile v1.15.0).
  • Masked emotes do not stack with other emotes: starting a masked emote stops a playing full-body emote (a chair's sitting emote included) and a full-body emote cancels a masked one; the mask only layers over locomotion. For a seated player, play a full-body clip that contains the seated pose instead. Authoring details in avatar-animations → "Upper-body (masked) animations".
  • Verified against protocol restricted_actions.proto / common/avatar_mask.proto (pinned in @dcl/sdk via protocol 0010e70) and sdk7-test-scenes 88,-13-avatar-masks (2026-07-16). Earlier speculative names AvatarEmoteMask / AEM_UPPER_BODY / AEM_FULL_BODY were never released — do not use them.

NPC Avatars

For creating NPCs (characters, shopkeepers, guards, etc.), see the npcs skill. It covers both the NPC Toolkit library (GLB-based, with dialogue and movement) and AvatarShape-based avatar NPCs.

A lookalike of the player (a queue of "you", a posed clone) is an `AvatarShape` fed from `getPlayer()` — not a baked model, so it matches whatever the player is wearing at the time. The exception is a double that must track the player every frame (mirror, shadow, replay): AvatarShape locomotion is engine-driven, so it lags behind and auto-faces its direction of travel — that one is a rigged GLB with an Animator, see npcs → "Copies of the player". Field mapping (avatar?.eyesColor → eyeColor, avatar?.bodyShapeUrn → bodyShape), posing the copy with expressionTriggerId, and the fact that avatars ignore VisibilityComponent are all in npcs → "Copies of the player".

Custom Nametag Plates (AvatarNametag)

Draws a plate with scene-provided text above an avatar's native nametag — for a rank, role, team, or title the scene assigns ("VIP", "Team Red", "Club Owner"). It does not replace the native nametag. Requires @dcl/sdk 7.28.0+ (not in 7.27.0 or earlier).

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

AvatarNametag.createOrReplace(engine.PlayerEntity, { label: 'Club Owner' })

A plain LWW component from @dcl/sdk/ecs — standard create / createOrReplace / getMutable / getOrNull / deleteFrom, no helper functions. label: string is required; labelColor, backgroundColor, borderColor are optional Color3 fields that fall back to the native nametag's colors (border defaults to the background, so no visible border). Full field table, label edge cases, component ids, and the multiplayer roster pattern: {baseDir}/references/avatar-nametag.md.

Valid targets: engine.PlayerEntity, any remote player entity (the ones carrying PlayerIdentityData), and any entity with an AvatarShape (NPCs — see the npcs skill). Writes to any other entity are silently ignored — no error, no plate.

Attaching to other players

Remote players are ordinary entities, so both idioms are one-liners:

typescript
import { onEnterScene } from '@dcl/sdk/src/players'
import { AvatarNametag } from '@dcl/sdk/ecs'

onEnterScene((player) => {
	AvatarNametag.createOrReplace(player.entity, { label: rankFor(player.userId) })
})
typescript
import { engine, AvatarNametag, PlayerIdentityData } from '@dcl/sdk/ecs'

for (const [entity, identity] of engine.getEntitiesWith(PlayerIdentityData)) {
	AvatarNametag.createOrReplace(entity, { label: rankFor(identity.address) })
}

AvatarNametag.deleteFrom(entity) removes the plate immediately; the native nametag is unaffected. To revert a color to its native default, use createOrReplace and omit the field — it clears fields you leave out, whereas getMutable keeps them.

Gotchas

  • No avatar-readiness dance. Writing to a still-loading avatar is fine — the renderer retries internally and applies the plate when the avatar is ready. Do NOT poll, delay, or try to detect avatar readiness before writing.
  • The plate is client-local and is never relayed, and player entity ids are recycled across disconnects — resolve the entity from the user id on every write (both idioms above do). For everyone to see the same tags, derive them from shared data (the roster pattern in the reference) or sync the assignment: multiplayer-sync → "Per-player visuals are NOT automatically shared".
  • Never call `createOrReplace` while iterating `engine.getEntitiesWith(...)`. Replacing a component can move the entity to a different archetype and invalidate the live iterator. Collect entities into an array first, then write.
  • Don't rewrite the component every tick. Compare against AvatarNametag.getOrNull(entity) before writing, or the scene emits a CRDT message per player per frame.
  • `label` is a single line. Long labels truncate with an ellipsis; there is no wrapping. For a bare colored plate with no text, use a spaces-only label (label: ' '; more spaces = wider plate). Setting labelColor equal to backgroundColor does not hide the text — the rendered text color is not an exact match to the plate, so the word stays faintly visible.
  • Colors have no alpha. Color3 only; there is no opacity field.
  • Not implemented on mobile (no ETA as of the Oct 2026 parity refresh). Mobile players see no plate at all, so never make it the only carrier of information — mirror rank/role in a TextShape, a HUD list, or a color the avatar already wears.
  • Plates are hidden along with the native nametag inside an AvatarModifierArea using AMT_HIDE_NAMETAGS or AMT_HIDE_AVATARS (see below). There is no way to keep the plate while hiding the native tag.

Avatar Modifier Areas

Modify how avatars appear or behave in a region.

typescript
import {
	engine,
	Transform,
	AvatarModifierArea,
	AvatarModifierType,
} from '@dcl/sdk/ecs'
import { Vector3 } from '@dcl/sdk/math'

const modifierArea = engine.addEntity()
Transform.create(modifierArea, {
	position: Vector3.create(8, 1.5, 8),
	scale: Vector3.create(4, 3, 4),
})

AvatarModifierArea.create(modifierArea, {
	area: Vector3.create(4, 3, 4),
	modifiers: [AvatarModifierType.AMT_HIDE_AVATARS],
	excludeIds: ['0x123...abc'], // Optional: exclude specific players
})

Available Modifiers

typescript
AvatarModifierType.AMT_HIDE_AVATARS // Hide all avatars in the area
AvatarModifierType.AMT_DISABLE_PASSPORTS // Disable clicking on avatars to see profiles
AvatarModifierType.AMT_HIDE_NAMETAGS // Hide the name tag above avatars in the area

modifiers is an array — combine several, e.g. [AMT_HIDE_NAMETAGS, AMT_DISABLE_PASSPORTS]. The region size is the explicit area: Vector3 field — the runtime sizes the region from a non-zero area and compensates out Transform.scale (a zero or unset area falls back to the scale, and a zero scale axis still collapses the region on that axis — SDKEntityTriggerAreaComponent, unity-explorer main). Still keep `Transform.scale` equal to `area`: the Creator Hub writes the gizmo scale into area whenever the entity is edited and draws the placeholder cube from the scale, so a mismatch shows the user a misleading box and the next editor save silently resizes the region to the scale. Set area to the dimensions you mean and mirror the same numbers in scale; never rely on scale alone to size the area. excludeIds is an array of wallet addresses that stay unaffected (e.g. keep the scene owner visible); mutate it at runtime via AvatarModifierArea.getMutable(entity).excludeIds = [...].

AMT_HIDE_AVATARS hides both avatars AND nametags — do not combine it with AMT_HIDE_NAMETAGS (redundant). Both modifiers also hide any AvatarNametag plate (see above); there is no way to hide the native tag while keeping the plate. Use AMT_HIDE_NAMETAGS only when you want nametags hidden while keeping avatars visible (e.g. stages, presentations, clean visual experiences). AMT_HIDE_NAMETAGS is combinable with AMT_DISABLE_PASSPORTS.

Nametag hiding is head/torso based: the nametag is hidden only while the player's head or torso is inside the area. If the area is too short, a player who double-jumps above it will have their nametag briefly reappear. Make the area tall enough to cover the expected range of movement.

GOTCHA — an explicit `area` wins over `Transform.scale` for the life of the component, so you cannot resize the region by scaling. The inverse world scale is re-applied every frame: scaling the area entity (or any of its parents — it compensates lossyScale) afterwards leaves the region exactly the area size, and the player's experience does not change. An axis scaled to 0 still collapses the region on that axis. Consequences:

  • Do not animate or tween the scale of an area entity to grow or shrink the region — nothing happens. Write AvatarModifierArea.getMutable(entity).area instead (and mirror it into scale for the editor, as above).
  • Do not parent an area under a group you scale, e.g. a prop rig that scales up on activation: the region stays put while the visuals grow, and the mismatch is invisible in-world because there is no API to read whether an avatar is hidden.
  • To visualise the region, use a separate box entity sized from the same numbers, as the regression scene does — a visible box on the area entity itself will lie the moment either value changes.
  • TriggerArea is the exact opposite: PBTriggerArea has no size field at all (only mesh and collision_mask), so its volume is the entity's Transform — scale it and the trigger follows, including scale changes made later. Two sibling components, opposite sizing rules; do not carry a habit from one to the other. See add-interactivity.

Regression coverage: `9,99-modifier-areas` (src/areaScaleDrift.ts), which scales a 2x2x2 CameraModeArea entity to 2.5 and asserts the camera still releases 2 m from the centre. It scales the entity itself only; the parent case follows from the code (inverse lossyScale), not from a test.

Creator Hub / Inspector support: the Creator Hub now has a dedicated inspector panel for AvatarModifierArea with a multi-select dropdown for modifiers (Hide Avatars, Disable Passports) and a wallet-address list editor for excludeIds. A "Avatar Modifier Area" smart item (utils category, translucent placeholder cube) is available in the asset catalog. The editor keeps the area field invisibly in sync with the entity's Transform.scale (the runtime sizes the region from a non-zero area and compensates out scale; a zero area falls back to the scale), so resizing the entity via the gizmo automatically updates the modifier region. That mirroring is an editor-time write, not a runtime binding — once the scene runs, area stands alone (GOTCHA above). Note: the inspector panel exposes only AMT_HIDE_AVATARS and AMT_DISABLE_PASSPORTS in its dropdown; AMT_HIDE_NAMETAGS is SDK-only for now. Verified against creator-hub commit a843390a.

Avatar Locomotion Settings

Adjust the player's movement speed and jump height:

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

// Modify run speed and jump height (set only the fields you want to change)
AvatarLocomotionSettings.createOrReplace(engine.PlayerEntity, {
	runSpeed: 14, // default is 10
	jumpHeight: 3, // default is 1
})

Fields (all float, optional) with client defaults — verified against unity-explorer origin/main CharacterControllerSettings.asset: walkSpeed (1.5), jogSpeed (8, the default movement speed), runSpeed (10), jumpHeight (1), runJumpHeight (1.5), doubleJumpHeight (2), glidingSpeed (6), glidingFallingSpeed (1), hardLandingCooldown (0.75s). See references/avatar-apis.md.

glidingFallingSpeed is a max descent cap — it limits how fast the player falls while gliding, but does not limit upward motion. While gliding, continuous scene forces are 1.5× stronger and can lift the player; see the player-physics skill ("Forces while gliding").

  • No negative values. Every field is clamped to >= 0; a negative value becomes 0. Setting a field to 0 has the same effect as blocking the matching key with InputModifier (e.g. runSpeed: 0 ≈ disableRun: true).
  • Scene beats smart wearable. If a scene and a smart wearable both set a field, the scene's value wins.
  • Scene bounds only. The component affects the player solely while they are inside the scene's bounds, and only the local player — to change another avatar's locomotion, the code must run on their client.
  • Enforcing the defaults is a real use case: a parkour or racing scene can write the default values explicitly so a smart wearable can't give its owner an advantage.

Creator Hub / Inspector support: a "Locomotion Settings" smart item (utils category, id f0fdd9ac-5451-4d84-9964-ea437b11b211) ships in the Smart Items pack. It is a asset-packs::Script item (locomotion-settings.ts, class LocomotionSettings) whose nine params are Slider fields seeded with the SDK defaults above, so a no-code user gets the same component without writing TypeScript. start() calls AvatarLocomotionSettings.createOrReplace(engine.PlayerEntity, {...}); it exposes two @actions — Apply (re-write the configured values) and Restore Defaults (AvatarLocomotionSettings.deleteFrom(engine.PlayerEntity)), so other smart items can trigger a speed boost and undo it. Its placeholder is an editor-only cube (asset-packs::Placeholder), invisible in-world. Prefer it over hand-built entities when the scene is open in the Creator Hub (see creator-hub-mcp place_smart_item). Verified against creator-hub commit cb097fff.

Restrict Locomotion (InputModifier)

Use InputModifier on engine.PlayerEntity to freeze or selectively restrict the player's movement — useful for cutscenes, locked interactions, or controlled game mechanics.

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

// Freeze all movement
InputModifier.create(engine.PlayerEntity, {
	mode: InputModifier.Mode.Standard({ disableAll: true }),
})

// Remove restrictions
InputModifier.deleteFrom(engine.PlayerEntity)

Behavior when frozen: gravity and external forces still apply, camera rotation stays available, global input events are still detectable, restrictions lift automatically when the player leaves scene bounds.

Standard-mode flags (all optional booleans, on InputModifier.Mode.Standard({...})): disableAll, disableWalk, disableJog, disableRun, disableJump, disableEmote, disableDoubleJump, disableGliding — all eight are in the SDK type (input_modifier.gen.d.ts) and documented, not protocol-only. Note disableJog is separate from disableWalk/disableRun — jog is the default movement speed, so disabling only walk+run still lets the player jog.

The mode can be built two equivalent ways — the InputModifier.Mode.Standard({...}) helper, or the raw discriminated union { $case: 'standard', standard: {...} }.

Tip: Combine with triggerSceneEmote — freeze the player during an animation, then remove InputModifier when it ends.

For the cutscene pattern, see the advanced-input skill.

Teleporting the Player

`movePlayerTo` from `~system/RestrictedActions` is the only way to relocate the player to a position. Setting Transform.getMutable(engine.PlayerEntity).position does NOT work (see the read-only warning at the top of this file). For sustained forces (lift, knockback, push, wind), use the player-physics skill instead — movePlayerTo is for explicit teleports/slides, not for forces.

movePlayerTo accepts:

  • newRelativePosition — where to move the player (scene-relative Vector3)
  • cameraTarget (optional) — a point in space for the camera to face after moving
  • avatarTarget (optional) — a point in space for the avatar to face after moving
  • duration (optional) — transition time in seconds; if provided, movement can be awaited

Constraints:

  • The player must already be inside the scene's bounds for this to work
  • The target position must also be within the scene's bounds
  • During the transition the avatar passes through colliders (verified: a CL_PHYSICS obstacle placed in the path is passed through)
  • Requires the ALLOW_TO_MOVE_PLAYER_INSIDE_SCENE permission in scene.json requiredPermissions
  • All fields except newRelativePosition are optional — cameraTarget and avatarTarget may each be omitted or used independently
  • The target Y may be elevated (e.g. y: 12) to place the player on a raised platform, not just ground level

Instant teleport

typescript
import { movePlayerTo } from '~system/RestrictedActions'

void movePlayerTo({
	newRelativePosition: Vector3.create(8, 0, 8),
	cameraTarget: Vector3.create(8, 1, 12),
	avatarTarget: Vector3.create(8, 1, 12),
})

Smooth transition with duration

When duration is set, movePlayerTo is awaitable. The resolved value has a success boolean — false if the player interrupted the movement with input.

typescript
import { movePlayerTo } from '~system/RestrictedActions'

async function teleport() {
	const result = await movePlayerTo({
		newRelativePosition: Vector3.create(1, 0, 1),
		cameraTarget: Vector3.create(8, 1, 8),
		duration: 2,
	})
	if (!result.success) {
		console.log('Movement was interrupted by the player')
	}
}

Prevent the player from interrupting a transition

Combine InputModifier with movePlayerTo to lock movement for the duration:

typescript
import { movePlayerTo } from '~system/RestrictedActions'
import { InputModifier, engine } from '@dcl/sdk/ecs'

async function lockedTeleport() {
	InputModifier.create(engine.PlayerEntity, {
		mode: InputModifier.Mode.Standard({ disableAll: true }),
	})

	await movePlayerTo({
		newRelativePosition: Vector3.create(1, 0, 1),
		cameraTarget: Vector3.create(8, 1, 8),
		duration: 2,
	})

	InputModifier.deleteFrom(engine.PlayerEntity)
}

Avatar Change Listeners

React to avatar changes in real-time:

typescript
import {
	AvatarEmoteCommand,
	EmoteState,
	AvatarBase,
	AvatarEquippedData,
} from '@dcl/sdk/ecs'

// Detect when the Explorer reports an emote playing on a player.
// AvatarEmoteCommand is written BY THE EXPLORER to report emote playback
// TO the scene -- it is NOT a signal from scene to renderer. It is appended
// to every player entity (local and remote alike).
// Each entry carries an optional `state` field (EmoteState enum) — see
// "Detecting when an emote finishes" above.
AvatarEmoteCommand.onChange(engine.PlayerEntity, (cmd) => {
	if (cmd) console.log('Emote:', cmd.emoteUrn, 'state:', cmd.state ?? EmoteState.ES_STARTED)
})

// Detect avatar appearance changes (wearables, skin color, etc.)
AvatarBase.onChange(engine.PlayerEntity, (base) => {
	if (base) console.log('Avatar name:', base.name)
})

// Detect equipment changes
AvatarEquippedData.onChange(engine.PlayerEntity, (equipped) => {
	if (equipped) console.log('Wearables changed:', equipped.wearableUrns)
})

Additional Anchor Points

Beyond the commonly used anchor points, the full list includes:

  • AvatarAnchorPointType.AAPT_POSITION — avatar feet position
  • AvatarAnchorPointType.AAPT_NAME_TAG — above the name tag
  • AvatarAnchorPointType.AAPT_LEFT_HAND / AAPT_RIGHT_HAND
  • AvatarAnchorPointType.AAPT_HEAD — head bone
  • AvatarAnchorPointType.AAPT_NECK — neck bone
Need to check the player's wallet before showing avatar items? See the nft-blockchain skill for wallet checks with getPlayer() and isGuest.

Example scenes

Engine-team test scenes (exercised against the real engine):

  • 100,102-avatar-attach-test — AvatarAttach on multiple anchor points; enumerates every player via PlayerIdentityData and attaches to player.address; a follower entity reconstructs the attached world position from PlayerEntity + attached Transform.
  • 80,-1-scene-emotes
  • 4,23-emote-finish — emote completion detection: AvatarEmoteCommand.onChange(engine.PlayerEntity, ...) logging every appended entry as STARTED / FINISHED / INTERRUPTED, with absent state defaulting to ES_STARTED. Play an emote out fully to see FINISHED; walk away mid-playback to see INTERRUPTED. Covers both triggerEmote (predefined) and a non-looping triggerSceneEmote. — triggerEmote, triggerSceneEmote (with a deliberately mis-named non-_emote.glb file shown NOT playing), stopEmote, mask: AvatarMask.AM_UPPER_BODY, plus loop: false + mask (plays once, returns to locomotion) and loop: true + mask (repeats until stopped).
  • 11,0-move-player-to-duration — movePlayerTo with duration, reading result.success via .then(), InputModifier locking input during the slide, and a CL_PHYSICS obstacle the avatar passes through mid-transition.
  • 9,99-modifier-areas — AvatarModifierArea (AMT_HIDE_AVATARS) with runtime-mutated excludeIds, alongside CameraModeArea.
  • 10,99-avatar-modifier-hide-nametags — AvatarModifierArea with AMT_HIDE_NAMETAGS: hides nametags while keeping avatars visible.
  • 4,24-avatar-nametag — AvatarNametag on the local player, remote players, and an AvatarShape NPC: every color field and its fallback, label edge cases, deleteFrom, AMT_HIDE_NAMETAGS, plus the multiplayer roster module (src/modules/multiplayerRoster.ts) documented in references/avatar-nametag.md.
  • 0,1-input-modifier — InputModifier toggling every Standard flag (disableAll/Walk/Jog/Run/Jump/Emote), both via the helper and the raw $case form.
  • 80,-4-restricted-actions — movePlayerTo (incl. elevated y, avatarTarget-only turns), triggerEmote, triggerSceneEmote, teleportTo, openExternalUrl.
  • 88,-13-avatar-masks — emote masks: looping AvatarMask.AM_UPPER_BODY scene emote + AvatarAttach anchor to hold a synced crate, stopEmote to release. Also includes loop: false + mask pair (plays once then returns upper body to locomotion) and loop: true + mask pair (repeats until stopped) for verifying the masked-emote loop flag is respected.

For component field details, see {baseDir}/../sdk-scenes/references/components-reference.md. For anchor points, emote names, and event callbacks, see {baseDir}/references/avatar-apis.md. For AvatarNametag fields, edge cases, and the multiplayer roster pattern, see {baseDir}/references/avatar-nametag.md.

do mesmo repositório

Mais Skills

Todos os Skills
decentraland
Comunidade

game-design

Plan and design Decentraland games and interactive experiences. Use when the user wants game design advice, scene architecture, performance planning, or help structuring a game. Do NOT use for specific implementation (see add-interactivity, build-ui, multiplayer-sync).

instalações
1
GitHub Stars
4
Atualizado
25 de out.
decentraland
Comunidade

lighting-environment

Dynamic lighting and environment in Decentraland scenes. LightSource, shadows, SkyboxTime, realm detection, and emissive materials. Use when the user wants lights, shadows, skybox control, day-night cycle, or glowing materials. Do NOT use for PBR material properties like metallic/roughness (see advanced-rendering).

instalações
1
GitHub Stars
4
Atualizado
25 de out.
decentraland
Comunidade

migrate-sdk6-to-sdk7

Migrate a legacy Decentraland SDK6 scene to SDK7. Use when the user wants to port an SDK6 scene, upgrade the decentraland-ecs dependency to @dcl/sdk, or rewrite class-based scene code in the ECS style. Do NOT use for new scenes (see create-scene) or for SDK7-to-SDK7 refactors.

instalações
1
GitHub Stars
4
Atualizado
25 de out.
decentraland
Comunidade

optimize-scene

Optimize Decentraland scene performance. Scene limit formulas, object pooling, LOD patterns, texture optimization, system throttling, and asset preloading. Use when the user wants to optimize performance, fix lag, reduce load time, check limits, or reduce entity/triangle count. Do NOT use for deployment (see deploy-scene).

instalações
1
GitHub Stars
3
Atualizado
11 de out.