Playing animations
The server decides what plays and when. Clients derive the pose from a shared clock.
How it works
An entity's animation is a small piece of state on the server, per controller: which clip is playing, the game tick it started at, a start offset, a playback rate and a loop mode. When it changes, the server sends the change (not frames) to the players tracking the entity. Each client works out the current pose from the world clock. Because the state is tiny and complete:
- a player who walks into range gets a snapshot and is immediately in sync;
- lag delays a change but never corrupts the timing, since the start tick is absolute;
- a resync is sent periodically (every 200 ticks) and on request, so a lost message heals itself;
- each change carries a per-controller sequence number, so a late packet can never overwrite a newer one.
A clip with no length. A clip whose length is 0 (a bone driven only by molang, such as math.sin(query.anim_time * 90)) loops without end: its query.anim_time keeps counting, in seconds. Molang's trigonometry is in degrees, so a rocking of one turn a second is math.sin(query.anim_time * 360).
Playing a clip
ServerAnimationStates.forEntity(entity, false)
.play("base", "walk", entity.getWorld().getTime(), 0, 1f, LoopMode.LOOP);
| Argument | Meaning |
|---|---|
entity, false | The entity. The boolean is for replaced entities (a Strut model on a vanilla entity); pass false for your own. |
"base" | The controller name. Use one controller per thing that can play at the same time (a body loop, a facial expression). Names are yours to choose. |
"walk" | The clip name inside the entity's .strutanim. |
| start tick | The game tick it starts at, normally the current world time. |
| offset | Where in the clip to start, in clip ticks. |
| rate | Playback speed. 1 is normal. |
| loop mode | LOOP, PLAY_ONCE or HOLD_ON_LAST_FRAME. |
Playing on a controller that is already playing replaces what was there.
Changing what is playing
long now = entity.getWorld().getTime();
ServerAnimationStates.forEntity(entity, false).setRate("base", 2f, now); // play twice as fast from now on
ServerAnimationStates.forEntity(entity, false).seek("base", 40f, now, false); // jump to tick 40 without firing events
ServerAnimationStates.forEntity(entity, false).stop("base", now); // stop
setRatechanges speed from that tick on without a jump: the pose continues from where it was.seekjumps to a clip tick. The last argument decides whether events between the old and new position fire (true) or are skipped silently (false).stopends the controller. Pass the game tick so events up to that moment are still delivered.
Controllers
You do not need to register controllers for server-driven animation: the client creates a default controller the first time the server plays on a name that has none. Register one yourself when you want to tune it (transition time, triggerable animations, sound handlers), or to animate on the client.
Blending. The server only says which clip plays; the client decides how it looks. When the clip a server-driven controller mirrors changes to a different clip, the controller blends from the pose it was showing into the new clip over its own transitionLength (ticks, set when you register the controller, for example new AnimationController<>(this, "body", 4, handler); the handler is not run for a server-driven controller, only the length is used). The target moves with the clip, so a clip joined mid-way blends into the right frame. There is no blend for the first clip a controller shows (an entity that comes into range mid-scene), for the same clip again (a rate change, a seek, a restart) or when the length is 0, and the default controller the client creates by itself has length 0, so register one to blend. Bones the old clip moved and the new one does not return to rest over the bone reset time, as always.
Blending out. A controller that plays over another (an arm's action over the legs' walk) can fade out instead of letting go of the bones at once when it stops: new AnimationController<>(this, "use", 5, handler).blendOut(true). Over its transition length each bone it was animating goes from where it left it to what the controllers registered before it give the bone, or to rest if none do. A chained animation (then(charge, PLAY_ONCE).thenLoop(hold)) blends from the last frame of one clip into the next.
Server or client decides
| Mode | Behaviour |
|---|---|
SERVER | The default for a StrutEntity. The server's state is the truth; the controller's handler is not consulted. |
CLIENT_COSMETIC | The default for items, block entities and everything that is not an entity. The client decides through the controller's handler. Opt an entity's controller into this with authorityMode(...) for purely visual animation. |
SERVER_WITH_LOCAL_PREDICTION | Reserved for later. It currently behaves like client-decided. |
A client-decided controller uses a handler that returns a state, exactly as in the block entity example on Items, armor and blocks.
Performance: level of detail
Animations far from the camera are evaluated less often. If an animatable's keyframe-driven sounds, particles or events must stay frame-exact at any distance (a boss telegraph, say), override useAnimationLod() to return false. Server-side events are not affected either way. See Rendering and performance.