decentraland/sdk-skills

animations-tweens

Animate objects in Decentraland scenes. Play GLTF model animations with Animator, create procedural motion with Tween, chain sequences with TweenSequence. Use when the user wants to animate, move, rotate, scroll a texture, or create motion effects. Do NOT use for audio/video playback (see audio-video), player emotes (see player-avatar), or physics-driven motion (see player-physics).

First seen Apr 13, 2026

Installation

$ npx skills add decentraland/sdk-skills --skill animations-tweens

Summary

  • Animate objects in Decentraland scenes.
  • Play GLTF model animations with Animator, create procedural motion with Tween, chain sequences with TweenSequence.
  • Use when the user wants to animate, move, rotate, scroll a texture, or create motion effects.
  • Do NOT use for audio/video playback (see audio-video), player emotes (see player-avatar), or physics-driven motion (see player-physics).

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from decentraland/sdk-skills · top by installs.

npx skills add decentraland/sdk-skills

Browse all from decentraland/sdk-skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 3
License LICENSE
Default branch main
Open issues 3
Status Active

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 28,080 B
  • docs SUMMARY.md 410 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 152 installs

SKILL.md

Animations and Tweens in Decentraland

When to Use Which Animation Approach

Need Approach When
Play animation baked into a .glb model Animator Character walks, door opens, flag waves — any animation from Blender/Maya
Move/rotate/scale an entity smoothly Tween Sliding doors, floating platforms, growing objects — procedural A-to-B motion
Chain multiple animations in sequence TweenSequence Patrol paths, multi-step doors, complex choreography
Continuous per-frame control engine.addSystem() Physics-like motion, following a target, custom easing, input-driven user control

Decision flow:

  1. Does the .glb already have the animation? → Animator
  2. Simple move/rotate/scale between two values? → Tween
  3. Need frame-by-frame control or custom math? → System with dt

GLTF Animations (Animator)

Play animations embedded in .glb models. Supports skeletal animations, object animations, and shape key (morph target) animations. Shape keys are useful for facial expressions, lip sync, or deformations.

Set up with Animator.create(entity, { states: [{ clip: 'idle', playing: true, loop: true, speed: 1 }] }). Play a single animation with Animator.playSingleAnimation(entity, 'walk'). Stop all with Animator.stopAllAnimations(entity). Get a clip with Animator.getClip(entity, 'Walk'). Blend animations by setting weight (0.0-1.0) on multiple states.

PITFALL: playSingleAnimation silently no-ops on clips not in states (programmatic Animator only)

Scope: this applies only when you author the Animator entirely in code and call Animator.playSingleAnimation(entity, clipName) programmatically. In many practical workflows the clip list is already populated for you — see "When you don't need to register clips manually" below.

The narrow fact (verified — @dcl/ecs/dist-cjs/components/extended/Animator.js lines 35-46): playSingleAnimation(entity, clipName) returns false and does nothing when clipName is not present in the entity's Animator.states array. The internal getClipAndAnimator helper returns a null state, and playSingleAnimation exits with if (!animator || !state) return false. There is no console warning.

So when you're building the Animator yourself in TypeScript and you intend to call playSingleAnimation with a clip name, that clip must already be in states[]:

Animator.create(ghost, {
  states: [
    { clip: "Idle_001", playing: true, loop: true },
    { clip: "Idle_002", playing: false, loop: true },
    { clip: "Appear", playing: false, loop: false },
    { clip: "Death", playing: false, loop: false },
    { clip: "Hit", playing: false, loop: false },
  ],
});

If you want SDK6's "play any clip, no setup required" ergonomics, wrap playSingleAnimation and lazily add missing states:

function playAnim(entity: Entity, clip: string, loop = false) {
  const a = Animator.getMutableOrNull(entity);
  if (!a) return;
  if (!a.states.find((s) => s.clip === clip)) {
    a.states.push({
      clip,
      playing: false,
      loop,
      shouldReset: true,
      speed: 1,
      weight: 1,
    });
  }
  Animator.playSingleAnimation(entity, clip, true);
  // playSingleAnimation already pauses all other states
}

When you don't need to register clips manually

Several common workflows populate the clip list for you — the rule above does not apply in these cases:

  • GltfContainer with no Animator attached. The renderer autoplays one of the GLB's clips on its own (observed: the first/default clip baked into the file). Confirmed first-hand in a Halloween scene where small ghosts had no Animator and the renderer autoplayed die straight from the GLB. Use this when the model should just spawn already animating and you don't need to switch clips at runtime.
  • Creator Hub Inspector placement. When you drop an asset into the Inspector, the composite is written with an Animator whose states array already includes every clip from the GLB. Creators using the Inspector never see the registration step because the editor does it.
  • Asset packs / smart items. These ship with states already populated by whoever authored the pack.

The "you have to pre-register" issue only bites the author-in-code + playSingleAnimation path. If a model is animating without any code-side Animator.create call, you're on the autoplay path described above — that's expected.

Related: if a GltfContainer model spawns playing the wrong clip (e.g. a death pose on what should be an idling NPC), the entity has no Animator and the renderer picked a clip you didn't intend. Add an Animator.create with the desired default clip set to playing: true to take control.

Resting an animated model at its FIRST frame (start-closed doors / graves / lids)

A common SDK6 → SDK7 port problem: a door/grave/coffin GLB whose "closed" pose is frame 0 of its open/trigger clip. Under SDK7 the renderer auto-plays a clip on load and holds its final frame, so the model spawns open instead of closed.

Verified fix (user-confirmed in-world): keep a state actively playing: true but frozen with speed: 0 and loop: false, so it holds frame 0 — the closed/rest pose — while still being a controlled, actively-playing state:

// Hold `clip` at its first frame (closed pose), under your control.
export function holdFirstFrame(entity: Entity, clip: string) {
  if (!Animator.has(entity)) Animator.create(entity, { states: [] })
  const a = Animator.getMutable(entity)
  if (!a.states.some((s) => s.clip === clip)) a.states = [...a.states, { clip }]
  Animator.stopAllAnimations(entity)
  const state = Animator.getClipOrNull(entity, clip)
  if (state) { state.playing = true; state.loop = false; state.speed = 0 }
}

Then, to open, set the same clip to speed: 1, playing: true (a normal playSingleAnimation / playClip call); to close, play the reverse/close clip. This is the observed behavior + working fix; the precise internals of which clip the renderer auto-plays are not documented in the SDK — treat it as renderer behavior and verify per model.

BEST PRACTICE: Short-circuit clip-switch helpers called from per-frame callers

Scope: this applies when you write your own clip-switch helper that mutates Animator.states directly (s.playing, s.loop, s.shouldReset) and that helper is invoked from a per-frame caller (an engine.addSystem update, an inputSystem/raycast callback that fires every tick, or any code path that runs each frame). The most common reason to write such a helper is porting an SDK6 scene that used lazy clip registration or noLoop + revertToIdle semantics ([[migrate-sdk6-to-sdk7]]).

Why it matters. A naive helper looks like this:

function playClip(entity: Entity, name: string, loop: boolean) {
  const a = Animator.getMutableOrNull(entity);
  if (!a) return;
  for (const s of a.states) {
    if (s.clip === name) {
      s.playing = true;
      s.loop = loop;
      s.shouldReset = true; // <-- rewritten every tick if helper is called per-frame
    } else {
      s.playing = false;
    }
  }
}

Called every frame, this calls Animator.getMutableOrNull() each tick, which marks the entity dirty in the CRDT layer. The ECS then serializes the component to bytes and compares against the last-sent snapshot (lww-element-set-component-definition.ts). Since the values are identical frame-to-frame, the CRDT suppression layer silently drops the update — the animation will NOT freeze. However, the per-frame serialization and byte comparison is unnecessary overhead that should be avoided.

Note on Animator.playSingleAnimation. Verified against @dcl/ecs/src/components/extended/Animator.ts: playSingleAnimation is NOT idempotent — it writes playing=false, shouldReset=true on every state in a loop, then playing=true, shouldReset=<arg> on the target. Calling it per-frame triggers the same unnecessary serialization overhead. Prefer to call playSingleAnimation from one-shot transitions (onPointerDown, state-machine edges) rather than from a system tick.

Fix — track the last-applied clip and short-circuit:

type Anim = { entity: Entity; lastClip?: string };

function playClip(a: Anim, name: string, loop: boolean) {
  if (a.lastClip === name) return; // already applied — skip redundant serialization
  const an = Animator.getMutableOrNull(a.entity);
  if (!an) return;
  for (const s of an.states) {
    if (s.clip === name) {
      s.playing = true;
      s.loop = loop;
      s.shouldReset = true;
    } else {
      s.playing = false;
    }
  }
  a.lastClip = name;
}

The short-circuit avoids calling getMutableOrNull() on unchanged frames, eliminating the per-frame serialization overhead. The CRDT delta is only sent on actual clip transitions.

SDK6-port subtlety — noLoop + revertToIdle. An SDK6-style helper that takes (clipName, noLoop, durationSec) and schedules a timers.setTimeout (from @dcl/sdk/ecs — never the native JS setTimeout) to revert to an idle clip must:

  • On a same-clip re-call (e.g. the player holds the beam and "Hit" keeps being requested every frame), only refresh the timer (clear + reschedule) so the clip keeps replaying for as long as input is held. Do not re-mutate Animator.states.
  • When the revert-to-idle timer fires, clear lastClip before re-calling the helper with the idle clip — otherwise the short-circuit prevents the idle from being applied if lastClip already equals the idle name from a prior tick.

When to prefer Animator.playSingleAnimation. It's the canonical write: pauses all other states in one pass and accepts a resetCursor argument (defaults to true). It's not idempotent — but it's the right call site when the trigger is a one-shot event. The custom-helper pattern only exists when porting SDK6 code that needs lazy clip registration (see the previous PITFALL) or noLoop + revertToIdle semantics.

Detecting Animation Completion

When a non-looping clip finishes, the engine flips that state's playing back to false — no setTimeout(clipLength) guessing needed. Poll with the READ-ONLY Animator.get() and edge-detect the true → false transition:

let wasPlaying = false
engine.addSystem(() => {
	const state = Animator.get(entity).states.find((s) => s.clip === 'Bite')
	const isPlaying = state?.playing ?? false
	if (wasPlaying && !isPlaying) {
		console.log('Bite finished')
		Animator.playSingleAnimation(entity, 'Walk') // chain the next clip
	}
	wasPlaying = isPlaying
})
  • Never poll with Animator.getClip() or getMutable() — both return a mutable ref and mark the component dirty every frame (same serialization overhead as the PITFALL above). Animator.get() is the only safe per-frame read.
  • The engine only flips the flag when the clip ends by itself. Looping clips never flip it, speed: 0 states never finish, and a scene-side stopAllAnimations() is your own write (track it yourself if you need to tell them apart).
  • Requires a DCL 2.0 desktop client with playback-completion support; on older clients the flag never flips — keep a timers.setTimeout fallback only if you must support them.

Tweens (Code-Based Animation)

Animate entity properties smoothly over time. Create with Tween.create(entity, { mode: Tween.Mode.Move/Rotate/Scale({start, end}), duration, easingFunction }). Duration is in milliseconds (1000 = 1 second). An entity can only have one Tween component at a time.

Helper methods (create or replace Tween directly):

  • Tween.setMove(entity, start, end, duration, easing?)
  • Tween.setRotate(entity, start, end, duration, easing?)
  • Tween.setScale(entity, start, end, duration, easing?)
  • Tween.setMoveRotateScale(entity, { position?, rotation?, scale?, duration }) — simultaneous

Continuous tweens (move/rotate at a constant speed). Third arg is speed (units per second, applied along the direction vector), NOT a duration. Optional final arg duration is a stop-after time in milliseconds0 or omitted = runs forever:

  • Tween.setMoveContinuous(entity, direction: Vector3, speed: number, duration?: number) — speed in meters/sec
  • Tween.setRotateContinuous(entity, direction: Quaternion, speed: number, duration?: number)speed is degrees per second (a full 360° revolution takes 360/speed seconds). The direction quaternion contributes only its rotation axis (its imaginary x,y,z part, sign preserved); its rotation angle/magnitude is ignoredQuaternion.fromEulerDegrees(0, 45, 0) and (0, 90, 0) both spin around +Y, only speed sets the rate. Negative speed reverses direction.
  • Tween.setTextureMoveContinuous(entity, direction: Vector2, speed: number, movementType?: TextureMovementType, duration?: number) — speed in UV units/sec

Texture scrolling (UV animation for waterfalls, conveyor belts):

  • Tween.setTextureMove(entity, start: Vector2, end: Vector2, duration, movementType?: TextureMovementType, easing?)movementType is the 5th param, easing 6th

Control: Tween.getMutable(entity).playing = false (pause), .currentTime = 0 (reset). Toggle a running continuous tween by reading Tween.getMutableOrNull(entity) and flipping .playing (verified in 78,-4-tweens). Remove a tween entirely with Tween.deleteFrom(entity); Tween.has(entity) checks presence.

Retrigger / replace at runtime: use Tween.createOrReplace(entity, {...}) (and TweenSequence.createOrReplace) instead of create when the entity may already have a tween — e.g. a platform re-triggered while still moving. Pass currentTime: 0 in the config to restart from the beginning in case it was mid-flight (verified in 77,-5-tweens-moving-platforms).

Move from the CURRENT position / retarget mid-travel: pass Transform.get(entity).position as start — the engine writes the tweened entity's interpolated Transform back to the scene every frame a tween is active, so that read is the live mid-flight position. Tween.setMove(entity, Transform.get(entity).position, target, duration, easing) therefore moves from wherever the entity currently is, and calling it again while a previous tween is still running smoothly redirects the entity mid-travel — no snap, no teleport (the set* helpers use createOrReplace, which re-triggers even with identical values) (verified in 79,-4-tween-following-cube).

PITFALL — omitting start on Move does NOT mean "current position": an unset start is treated as (0,0,0) — the entity teleports to the scene origin before moving. A hardcoded stale start likewise causes a visible teleport to that point before the motion begins. Always supply start, and read Transform.get(entity).position when you mean "from wherever it is now" (verified in 79,-4-tween-following-cube).

PITFALL — a CONSTANTLY changing target needs setMoveContinuous, not repeated setMove: re-creating a Move tween every frame to chase a moving target (following the player, homing at a runaway entity) makes the entity visibly stutter. Transform.get(entity).position is what the renderer last wrote back over CRDT, so it trails the entity's real position by ~1-3 frames; the renderer applies the new start the instant it arrives, snapping the entity backwards on every re-aim. One correction is invisible, ~30/second is jitter — and raising the re-aim threshold only makes the stutter coarser, since the replacement frequency is the problem. Use Tween.setMoveContinuous(entity, direction, speed) instead: continuous modes take their start from the renderer's own live transform, so replacing them mid-motion cannot snap. Caveats: it has no destination (stop it yourself with Tween.deleteFrom on a distance check, or pass the optional stop-after duration), and you should only re-aim when the direction has changed materially, or you send a CRDT update per frame. Discrete retargeting (clicks, waypoints) is still fine with setMove (verified in 79,-4-tween-following-cube, which switches between both modes live).

Tween.Mode.MoveRotateScale({...}) and setMoveRotateScale accept a partial transform — supply only the axes you want (position / rotation / scale); at least one is required. Omitted axes are left unchanged (verified against SDK source + 78,-4-tweens).

Texture movement type (setTextureMove / setTextureMoveContinuous, and Tween.Mode.TextureMove): the movementType param is TextureMovementType.TMTOFFSET (default, =0 — scrolls the UV offset) or TextureMovementType.TMTTILING (=1 — animates the tiling). Import TextureMovementType from @dcl/sdk/ecs. The texture must use wrapMode: TextureWrapMode.TWM_REPEAT for continuous scrolling to tile seamlessly (verified in 0,3-texture-movement, 78,-4-tweens).

Tween Sequences (Chained Animations)

Chain with TweenSequence.create(entity, { sequence: [...tweenConfigs], loop }). Loop modes: TweenLoop.TLRESTART (=0, loop from start), TweenLoop.TLYOYO (=1, reverse at each end).

PATTERN: empty sequence loops the base Tween

TweenSequence.create(entity, { sequence: [], loop: TweenLoop.TLYOYO }) on an entity that already has a plain Tween makes that base tween itself loop/yoyo — no sequence steps needed. This is the idiomatic way to make a single setMove/setRotate/setScale/setTextureMove repeat forever (verified in 77,-5-tweens-moving-platforms, 0,3-texture-movement). Use TLYOYO for back-and-forth (platform bobbing), TL_RESTART for a hard reset each cycle (scrolling texture, one-directional spin).

For a multi-waypoint path, the base Tween is the first leg and sequence[] holds the remaining legs; each step's start/end must chain from the previous leg's end (verified in 77,-5-tweens-moving-platforms platform4).

Detecting Tween Completion

Use tweenSystem.tweenCompleted(entity) in an engine.addSystem() to check if a tween finished this frame.

Easing Functions

Available from EasingFunction: EFLINEAR, EFEASEINQUAD/EASEOUTQUAD/EASEQUAD, EFEASEINSINE/EASEOUTSINE/EASESINE, EFEASEINEXPO/EASEOUTEXPO/EASEEXPO, EFEASEINELASTIC/EASEOUTELASTIC/EASEELASTIC, EFEASEOUTBOUNCE/EASEINBOUNCE/EASEBOUNCE, EFEASEINBACK/EASEOUTBACK/EASEBACK, EFEASEINCUBIC/EASEOUTCUBIC/EASECUBIC, EFEASEINQUART/EASEOUTQUART/EASEQUART, EFEASEINQUINT/EASEOUTQUINT/EASEQUINT, EF_EASEINCIRC/EASEOUTCIRC/EASECIRC.

Custom Animation Systems

For complex animations, create a system with engine.addSystem((dt) => { ... }) and modify Transform.getMutable(entity) each frame. Use a custom component (e.g. Spinner) to mark which entities need animating.

Troubleshooting

Problem Cause Solution
GLTF animation not playing Wrong clip name Check exact clip names (case-sensitive) in a viewer
playSingleAnimation does nothing, returns false Clip name not in Animator.states for an Animator you built in code Add the clip to states[] at Animator.create time, or use the lazy-add wrapper above. Only applies when you authored the Animator programmatically — Inspector / asset-pack Animators are already pre-populated
Model autoplays an unexpected animation on spawn No Animator component — GltfContainer autoplays one clip from the .glb (the same mechanism that lets clip-less Inspector scenes animate without manual registration) Add Animator.create with the intended default clip set to playing: true to take control
Door/grave/lid spawns OPEN instead of closed The renderer auto-plays the clip and holds its final (open) frame; the model's closed pose is frame 0 of that clip Hold frame 0 with a playing: true, speed: 0, loop: false state — see "Resting an animated model at its FIRST frame" above
Unnecessary per-frame serialization overhead Clip-switch helper calls getMutableOrNull every tick with identical values (custom helper or playSingleAnimation called from a system / per-frame input callback) Track the last-applied clip and short-circuit when unchanged, or only call the helper on state transitions — see "Short-circuit clip-switch helpers" best practice above
Animator has no effect Missing GltfContainer Animator only works on entities with a loaded GLTF model
Need to know when a non-looping clip ends Guessing with timers.setTimeout(clipLength) Edge-detect playing flipping to false via read-only Animator.get() — see "Detecting Animation Completion" above
Tween doesn't move Same start and end Verify values differ in Tween.Mode.Move()
Entity teleports when a Move tween starts start doesn't match the entity's current position — omitted start = (0,0,0) (scene origin), hardcoded/stale start = wherever you wrote Pass Transform.get(entity).position as start — it's the live position even while a previous tween is mid-flight
Tween plays once then stops No loop Add TweenSequence with loop: TweenLoop.TL_YOYO
Animation jitters Creating Tween every frame Only create Tween once, not inside a system
Intro animation/tween is over by the time the player sees the scene It was started at scene startup and played out behind the Explorer's loading screen Gate it on EngineInfo.sceneHidden flipping to false (the loading-screen fade-out) — see the scene-runtime skill

For full code examples (Animator setup, all tween types, sequences, helpers, texture scrolling), see {baseDir}/references/animation-patterns.md.

Example scenes

Engine-team test scenes (ground-truth API usage):

  • https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/73,-8-animator-tests-sharkAnimator with two states + weight; clicking cycles playSingleAnimation('swim') / ('bite') and demonstrates concurrent blend by also setting getClip('bite').playing = true.
  • https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/73,-9-animation-finish — animation-completion detection: non-looping bite finish flips playing to false (observed via read-only Animator.get()), chains into swim; proves looping clips never report finish.
  • https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/78,-4-tweens — every finite mode (setMove/setRotate/setScale/setTextureMove/setMoveRotateScale), continuous modes, createOrReplace, deleteFrom, pause-toggle via getMutableOrNull().playing, and mixed-mode TweenSequence.
  • https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/77,-5-tweens-moving-platformsTween.setMove + empty-sequence TL_YOYO for bobbing platforms, multi-waypoint path via sequence[], and createOrReplace retrigger from a TriggerArea.
  • https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/79,-4-tween-following-cube — a cube that chases the player, with a pad that switches live between setMoveContinuous (smooth, the default) and re-creating a setMove tween from Transform.get(entity).position on every re-aim (visibly jittery). Both modes share the same re-aim trigger and speed, so the difference is attributable to the tween mode alone. Also shows Billboard with BM_Y on floating labels.
  • https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/0,3-texture-movementsetTextureMove with TMTOFFSET vs TMTTILING, empty-sequence loop/yoyo, TWM_REPEAT tiling.
  • https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/10,0-move-to-test — custom per-frame spin system (Quaternion.multiply + fromAngleAxis(dt*speed, Up())) marking entities with a Spinner component. (The movePlayerTo portion belongs to restricted-actions, not tweens.)