AzCommands 101

AzCommand is a highly flexible class for controlling animations in AzureLib by dispatching a sequence of animation-related actions. It simplifies animation management by allowing you to manipulate the animation system at three hierarchical levels: root, controller, and animation. This guide will walk you through the basics of using AzCommand and how to replace the traditional AnimationController approach with it.

The Three Configuration Layers:

  • Root: Applies to every controller at once.
  • Controller: Applies to one named controller. Playback properties (speed, transition length, offsets, repeat count, freeze point, direction) live here and stay set until something changes them.
  • Animation: Applies to one animation (a stage of a sequence): its play behavior and, optionally, its own speed, transition length, easing, start offset, freeze point, repeat count and direction. These only last while that animation plays, then the controller's settings apply again (see Stage Settings).

Getting Started with AzCommand

1. Creating an AzCommand

Creating an AzCommand involves specifying the controller name, the animation name, and optionally the play behavior (e.g., loop, play once). Here's how:

Example:

AzCommand walkCommand = AzCommand.create("base", "WALK_ANIMATION");

In this example:

  • "base" is the controller name.
  • "WALK_ANIMATION" is the animation to play.
  • No play behavior is given, so the animation plays the way its animation file says: the Loop Mode set in Blockbench (see Setting Play Behaviors in Blockbench). Animations without a loop mode play once.

To choose the behavior in code instead, pass it as the third argument. A behavior set in code always wins over the animation file:

Example:

AzCommand idleCommand = AzCommand.create("base", "IDLE_ANIMATION", AzPlayBehaviors.LOOP);

Play Behaviors

BehaviorWhat it doesFinishes?
AzPlayBehaviors.PLAY_ONCEPlays once, then moves on to the next stage (or stops).Yes
AzPlayBehaviors.LOOPLoops forever.No
AzPlayBehaviors.HOLD_ON_LAST_FRAMEPlays once and holds the last frame.No
AzPlayBehaviors.PING_PONGPlays forward, then backward, forever, with seamless turnarounds.No
AzPlayBehaviors.REPEAT_X_TIMESPlays the number of times set by the repeat count, then moves on.Yes
AzPlayBehaviors.FREEZE_ON_FRAMEPlays until the freeze point, then holds that frame.No
AzPlayBehaviors.AS_AUTHOREDUses the Loop Mode from the animation file. This is what create(controller, animation) uses.Depends on the file
  • Repeat count is the total number of plays: 3 plays the animation three times. 1 or less plays it once.
  • Freeze point is set in ticks when set from code (freezeTickOffset), and in seconds in Blockbench. It only ever applies to animations playing with FREEZE_ON_FRAME; a FREEZE_ON_FRAME animation with no freeze point holds its last frame.
  • If a repeat count or freeze point is set in more than one place, the stage's value wins, then the controller's, then the animation file's.

Setting Play Behaviors in Blockbench

With the AzureLib Animator plugin (2.2.0+), open an animation's Properties (double-click it in the Animations panel, or right-click → Properties) and pick its Loop Mode: Once, Hold, Loop, Ping-Pong, Repeat or Freeze. Repeat adds a Repeat Count field, and Freeze adds a Freeze At time with a Use Playhead Time button. Blockbench previews all of them on the timeline.

These are saved into the animation file, so in most cases you can play animations with AzCommand.create(controller, animation) and never set the behavior in code:

"attack": { "loop": "repeat_x_times", "repeat_times": 3 },
"hover": { "loop": "ping_pong" },
"pose": { "loop": "freeze_on_frame", "freeze_at": 1.25 }

Setting Playback Properties

To play an animation and set every playback property in one call, use the full create overload:

Example:

AzCommand slamCommand = AzCommand.create(
"base", // controller
"SLAM_ANIMATION", // animation
AzPlayBehaviors.PLAY_ONCE, // play behavior
5F, // start tick offset: skip the first 5 ticks
1.5F, // animation speed: 50% faster
4F, // transition length: blend in over 4 ticks
0F, // freeze tick offset (FREEZE_ON_FRAME only)
1F, // repeat count (REPEAT_X_TIMES only)
false // play in reverse
);

AzCommand.createRoot(...) takes the same arguments without the controller name and applies them to every controller.

To change only some properties, or change them without playing anything, use AzCommand.controllerBuilder() (one controller) or AzCommand.rootBuilder() (all controllers):

Example:

AzCommand sprintCommand = AzCommand.controllerBuilder()
.setSpeed("base", 2F)
.play("base", "RUN_ANIMATION")
.build();
Controller builder methodRoot builder methodSets
setSpeed(controller, speed)setSpeed(speed)Playback speed multiplier (1 = normal).
setTransitionSpeed(controller, ticks)setTransitionSpeed(ticks)Transition length in ticks when switching animations.
setStartTickOffset(controller, ticks)setStartTickOffset(ticks)Ticks skipped at the start of each animation (not scaled by speed).
setFreezeTickOffset(controller, ticks)setFreezeTickOffset(ticks)Freeze point for FREEZE_ON_FRAME.
setRepeatAmount(controller, count)setRepeatAmount(count)Total plays for REPEAT_X_TIMES.
setReverseAnimation(controller, reverse)setReverseAnimation(reverse)Play direction (see Playing Animations in Reverse).
setEasingType(controller, easing)setEasingType(easing)Easing used for transitions.
setWeight / fadeWeight—Layer weight (see Changing a Controller's Weight).
play(controller, animation) / playSequence(...)playSequence(...)Plays an animation or sequence.
cancel(controller)cancelAll()Stops what's playing.

Playing Animations in Reverse

Any animation can play backwards, from its last frame to its first. Keyframes, Molang query.anim_time and keyframe events (sounds, particles, custom instructions) all run in reverse, and transitions blend into the animation's last frame instead of its first.

Reverse a single animation as part of a sequence:

AzSequence closeDoor = AzSequence.create().playReversed("DOOR_OPEN_ANIMATION");

Or reverse a whole controller:

AzCommand.controllerBuilder()
.setReverseAnimation("door", true)
.build()
.sendForBlockEntity(myDoor);
  • If the controller is in the middle of an animation, it turns around from its current pose instead of jumping. A half-open door closes from where it is.
  • A direction set on the animation (playReversed, or withShouldReverse(true) in a stage) wins over the controller's direction.
  • For an animation that plays forward and then back forever, use AzPlayBehaviors.PING_PONG.

2. Playing Sequences

An AzSequence plays several animations one after another on one controller:

Example:

static final AzSequence SLAM = AzSequence.builder()
.play("WINDUP_ANIMATION") // plays once, then continues
.play("STRIKE_ANIMATION")
.loop("IDLE_ANIMATION") // loops forever
.build();
SLAM.toCommand("base").sendForEntity(myEntity);
MethodStage
play(name)Plays once and continues.
loop(name)Loops forever.
hold(name)Holds the last frame.
pingPong(name)Plays forward and backward forever.
playReversed(name)Plays once backwards and continues.
authored(name)Uses the animation file's Loop Mode.
freeze(name, tick)Plays until tick, then holds that frame.
then(name, behavior)Any behavior.
then(name, behavior, props -> ...)Any behavior with stage properties, e.g. props -> props.withShouldReverse(true).
  • Stages that never finish (loop, hold, ping-pong, freeze) must be last. Adding a stage after one throws an IllegalStateException. authored(...) can't be checked this way, so only add stages after it if the file's Loop Mode finishes.
  • Use toCommand(controller) for one controller, or toRootCommand() for all of them.
  • Sending a sequence that is already playing does nothing, so it's safe to send it every tick.

Stage Settings

Besides its behavior, each stage can set its own playback properties through the props -> ... customizer of then:

static final AzSequence DIAL = AzSequence.builder()
.play("RING_SPIN")
.then("CHEVRON_LOCK", AzPlayBehaviors.PLAY_ONCE, props -> props.withAnimationSpeed(2))
.then("KAWOOSH", AzPlayBehaviors.PLAY_ONCE, props -> props.withTransitionLength(10))
.then("RING_SPIN", AzPlayBehaviors.FREEZE_ON_FRAME, props -> props.withFreezeTickOffset(18))
.build();
Stage propertySets
withAnimationSpeed(speed)Playback speed of this stage, including its transition.
withTransitionLength(ticks)Length of the blend into this stage.
withEasingType(easing)Easing used by this stage.
withStartTickOffset(ticks)Ticks skipped at the start of this stage.
withFreezeTickOffset(ticks)Freeze point, for FREEZE_ON_FRAME stages.
withRepeatXTimes(count)Total plays, for REPEAT_X_TIMES stages.
withShouldReverse(reverse)Direction of this stage.
  • Stage settings are layered over the controller's settings while that stage plays. Anything the stage doesn't set comes from the controller.
  • Unlike the controller builder's setters, they never stick: the next stage, or the next command, plays with the controller's settings again. Prefer them whenever only one animation needs different settings.

Sequence Events

Sequence events are named points in time that run on the side that ticks an AzSequencePlayer, usually the server, which makes them suitable for gameplay such as dealing damage on the right frame. There are two kinds:

MethodFires
event(name, tick)A fixed number of game ticks after the sequence starts, wherever it appears in the chain.
eventAt(name, keyTick)When the stage it follows reaches keyTick on its own timeline (the tick shown in Blockbench).
eventOnStart(name)When the stage it follows starts playing, after the transition into it.
eventOnEnd(name)When the stage it follows finishes: after its last repeat, at its freeze point, or after its first pass.

eventAt, eventOnStart and eventOnEnd are anchored to their stage. The player works out when they happen from the controller's speed and transition length, each stage's own settings, and the length of each animation, so they stay lined up with the animation when any of those change. event(name, tick) ignores all of that.

Example:

static final AzSequence SLAM = AzSequence.builder()
.play("WINDUP_ANIMATION")
.play("STRIKE_ANIMATION")
.eventAt("damage", 6) // when STRIKE_ANIMATION reaches tick 6
.eventOnEnd("recovered") // when STRIKE_ANIMATION finishes
.build();
private final AzSequencePlayer attackPlayer = AzSequencePlayer.forEntity(this, "base", (sequence, event) -> {
if (event.is("damage")) {
dealSlamDamage();
}
}).withTiming(AzSequenceTiming.builder()
.transitionLength(4) // the same value the "base" controller was built with
.length("WINDUP_ANIMATION", 20) // animation lengths in ticks, as shown in Blockbench
.length("STRIKE_ANIMATION", 12)
.build());
public void startSlam() { // server side, e.g. from Goal#start
attackPlayer.play(SLAM);
}
@Override
public void tick() {
super.tick();
if (!level().isClientSide()) {
attackPlayer.tick();
}
}
  • Give the player the controller's settings. AzSequenceTiming should match how the controller was built (speed, transition length, start offset). Settings a stage sets for itself are read from the sequence, so you don't repeat those.
  • Give it the animation lengths anchored events depend on. Animation files only exist on the client, so the server can't look them up. An event needs the lengths of every stage before it, plus its own stage's length for eventOnEnd or a reversed stage. An event whose time can't be worked out is skipped with a warning instead of firing at the wrong moment.
  • On the client, controller.sequenceTiming() builds the timing from a live controller and reads every length from the animation file.
  • Stages played with authored(...) are timed as play-once stages, since the server can't read the file's Loop Mode. Give a stage an explicit behavior if later events depend on it repeating or freezing.
  • event(name, tick) counts game ticks from the start of the whole sequence and isn't affected by speed or transitions.
  • Anchored events on looping, ping-pong and repeating stages fire on the first pass only.
  • Changes made to the controller while the sequence runs (another command changing its speed, say) aren't seen by the player, and the client starts the sequence when the command arrives, so allow about a tick of slack.
  • If another animation takes over the controller (hurt, death), call attackPlayer.cancel() so pending events don't fire.
  • To inspect when everything happens, SLAM.timeline(timing) returns each stage's start and end and each event's tick.

3. Composing Multiple Commands

If you need to merge several commands into one, you can use the compose method. This is helpful for combining animations across multiple controllers:

AzCommand combinedCommand = AzCommand.compose(walkCommand, idleCommand);

All actions from the two commands are merged into a single unified command.

4. Changing a Controller's Weight

When several controllers are layered on the same bones (see the Layering Controllers section of the Animation Controller Builder page), you can change how strongly a controller is applied from the server. Build the command with AzCommand.controllerBuilder():

// Fade the "aim" layer in over 5 ticks.
AzCommand aimIn = AzCommand.controllerBuilder()
.fadeWeight("aim", 1F, 5F)
.build();
// Set it back to 0 instantly.
AzCommand aimOff = AzCommand.controllerBuilder()
.setWeight("aim", 0F)
.build();
aimIn.sendForEntity(myEntity);
  • setWeight(controllerName, weight) changes the weight instantly; fadeWeight(controllerName, weight, ticks) moves it there over the given number of ticks.
  • Weights range from 0 (no effect) to 1 (full).
  • A weight stays in place until it is changed again; playing a new animation on the controller doesn't reset it.
  • The same builder can also play animations, so a layer can be faded in and started in one command:
AzCommand startAiming = AzCommand.controllerBuilder()
.fadeWeight("aim", 1F, 5F)
.play("aim", "AIM_ANIMATION")
.build();

5. Random Animation Pools

A pool plays a random animation from a weighted list, and picks the next one each time the current animation finishes. This is the way to get idle or walk variety (idle, then idle2, then idle again) without timers.

Because the next pick happens when the animation actually ends, nothing about animation length is hardcoded. If a resource pack retimes or replaces an animation, the pool follows it automatically.

Creating a Pool

Build a pool with AzWeightedPoolBehavior.builder, giving it a unique name and one entry per animation:

Example:

public static final AzWeightedPoolBehavior IDLE_POOL = AzWeightedPoolBehavior.builder("mymod:example_idle")
.add("IDLE_ANIMATION", 6)
.add("IDLE_STRETCH_ANIMATION", 3)
.addNoRepeat("IDLE_SNIFF_ANIMATION", 1)
.build();

In this example:

  • "mymod:example_idle": The pool's name. It must be unique across all mods, so prefix it with your mod id.
  • 6, 3, 1: Relative weights. These three give 60%, 30% and 10%. Weights don't need to add up to anything in particular.
  • addNoRepeat: This animation is never picked twice in a row. See Preventing Repeats.

Playing a Pool

Turn the pool into a command with the controller it should play on, then send it like any other command:

Example:

private static final AzCommand IDLE_COMMAND = IDLE_POOL.sequence().toCommand("base");
IDLE_COMMAND.sendForEntity(myEntity);
  • sequence() always starts the pool on its first entry, or on the entry chosen with startWith(...).
  • It's safe to send the same pool command every tick. Sending the pool that's already playing does nothing, so it won't restart the chain.
Starting on a Random Entry

sequence() always starts on the same animation, which means that animation shows up more often than its weight suggests when the pool only runs briefly. Use randomSequence() to start on a weighted-random entry instead:

Example:

private AzCommand currentIdle;
// When the entity enters its idle state:
currentIdle = IDLE_POOL.randomSequence().toCommand("base");
// Every tick while idle:
currentIdle.sendForEntity(this);
Using a Pool After Other Animations

A pool can be the last stage of a sequence. The earlier stages play once, then the pool takes over:

Example:

AzSequence.create()
.play("WAKE_UP_ANIMATION")
.then("IDLE_ANIMATION", IDLE_POOL)
.toCommand("base")
.sendForEntity(myEntity);
  • The pool should be the last stage. If other stages come after it, the pool plays its first animation and then moves on to them, without picking.

Preventing Repeats

Base loops are usually designed to repeat seamlessly, but detail animations such as a sniff or a head turn look obvious when they play twice in a row. You can block repeats per animation:

Example:

AzWeightedPoolBehavior.builder("mymod:example_walk")
.add("WALK_ANIMATION", 1) // may repeat
.addNoRepeat("WALK_SNIFF_ANIMATION", 1) // never twice in a row
.add("WALK_LOOK_ANIMATION", 1, false) // same as addNoRepeat
.build();
  • avoidImmediateRepeat() on the builder marks every entry as no-repeat at once.
  • Blocking repeats slightly lowers how often that animation appears overall, because it's sometimes excluded. For example, with IDLE at weight 3 and a no-repeat SNIFF at weight 1, the sniff appears 20% of the time instead of 25%. Raise its weight a little (1.5 here) if you need the original share.

Things to Know

  • Picks happen on each client. Two players watching the same entity may see different variants. That's usually fine for idles and walks. If every player must see the same animation, choose it on the server and send a normal command instead.
  • Missing animations are skipped. If a resource pack removes an entry, the pool tries the other entries. If none of them can be found, the current animation loops. A warning is logged for each missing animation.
  • Blending uses the controller's transition length, exactly like switching between any two animations. See the Animation Controller Builder page.
  • Switching between pools restarts them. If you switch between an idle pool and a walk pool based on movement, make sure the movement check is stable. A check that flickers for a single tick (for example, between path nodes) will keep restarting the pools and you'll only ever see the first animation. Requiring the new state to hold for a few ticks before switching fixes this.

Full Example

public class ExampleEntity extends PathfinderMob {
public static final AzWeightedPoolBehavior IDLE_POOL = AzWeightedPoolBehavior.builder("mymod:example_idle")
.add("IDLE_ANIMATION", 3)
.addNoRepeat("IDLE_SNIFF_ANIMATION", 1)
.build();
public static final AzWeightedPoolBehavior WALK_POOL = AzWeightedPoolBehavior.builder("mymod:example_walk")
.add("WALK_ANIMATION", 1)
.addNoRepeat("WALK_SNIFF_ANIMATION", 1)
.addNoRepeat("WALK_LOOK_ANIMATION", 1)
.build();
private static final int STATE_SWITCH_TICKS = 5;
private boolean walking;
private int pendingStateTicks;
private AzCommand currentCommand;
public ExampleEntity(EntityType<? extends PathfinderMob> entityType, Level level) {
super(entityType, level);
}
@Override
public void tick() {
super.tick();
// Horizontal movement only, and the new state must hold for a few ticks before it counts.
var dx = getX() - xo;
var dz = getZ() - zo;
var movingNow = dx * dx + dz * dz > 1.0E-4;
if (movingNow == walking) {
pendingStateTicks = 0;
} else if (++pendingStateTicks >= STATE_SWITCH_TICKS) {
walking = movingNow;
pendingStateTicks = 0;
currentCommand = null; // pick a new start for the new state
}
if (!level().isClientSide()) {
return; // Only dispatch on the client, so the server doesn't send a packet every tick
}
if (currentCommand == null) {
var pool = walking ? WALK_POOL : IDLE_POOL;
currentCommand = pool.randomSequence().toCommand("base");
}
currentCommand.sendForEntity(this);
}
}

Sending AzCommands

Once you've created an AzCommand, you can dispatch it to an entity, block entity, or item stack using the provided methods.

Send to an Entity

To trigger an animation on an entity, use the sendForEntity method:

walkCommand.sendForEntity(myEntity);
  • The command will determine the proper dispatch based on the client or server side.

Send to a Block Entity

To send a command to a BlockEntity:

idleCommand.sendForBlockEntity(myBlockEntity);

This sends the command to all clients tracking the relevant chunk.

Send to an Item/Armor

To animate an item, use the sendForItem method:

combinedCommand.sendForItem(myEntity, myItemStack);

This requires the item stack to have a registered UUID, which is covered in the Item and Armor guides.

Sending to a Specific View

If the item's renderer uses useSeparateFirstPersonAnimator() (see the AzItemRendererConfig section), the stack has two animators: one for first person and one for third person and every other context. sendForItem(entity, stack) plays the command on both. To play it on only one, target a view:

private static final AzCommand AIM_FIRING = AzCommand.create("base_controller", "aimfiring", AzPlayBehaviors.PLAY_ONCE);
private static final AzCommand FIRING = AzCommand.create("base_controller", "firing", AzPlayBehaviors.PLAY_ONCE);
public void fire(Player player, ItemStack stack) {
AIM_FIRING.sendForItemFirstPerson(player, stack); // what the shooter sees
FIRING.sendForItemThirdPerson(player, stack); // what everyone else sees
}
MethodPlays on
sendForItem(entity, stack)Every animator the stack has.
sendForItemFirstPerson(entity, stack)The first-person animator only.
sendForItemThirdPerson(entity, stack)The default animator only (third person, GUI, ground, item frames and other contexts).
sendForItem(entity, stack, view)The given AzItemAnimatorView, or every animator when view is null.
  • A view's animator is created the first time the item renders in that view, the same as the default animator. Commands sent before then are dropped.
  • Players spectating someone in first person render that player's first-person animator, so they see the first-person animation.
  • If a command for every view and a command for one view target the same controller, the one sent last wins on that view.

Best Practices

  1. Reuse Commands:
    • Define reusable commands for common animations to simplify your rendering logic.
  2. Use pools instead of timers for variety:
    • To randomly vary idle or walk animations, use a Random Animation Pool rather than counting ticks. Pools follow the real animation length, so they keep working when a resource pack changes an animation.
  3. Set play behaviors in Blockbench:
    • Choosing the Loop Mode in Blockbench keeps it next to the animation and lets you preview it. Then AzCommand.create(controller, animation) is all your code needs. Pass a behavior in code only when it must differ from the file.
  4. Remember that controller properties stick:
    • Speed, transition length, offsets, repeat count and direction stay on the controller after a command sets them. If one animation needs different settings, set them on its sequence stage instead, so they only apply while it plays.

Converting from AnimationController to AzCommand

If you previously managed animations using AnimationController, you can streamline and simplify your code using AzCommand. This approach eliminates the need for directly managing AnimationController states by leveraging AzCommand to dispatch animations dynamically.


Old Approach (Using AnimationController)

@Override
public void registerControllers(AnimatableManager.ControllerRegistrar controllers) {
controllers.add(new AnimationController<>(this, "Walk", 5, state -> {
if (state.isMoving()) {
return state.setAndContinue(WALK_ANIMATION);
}
return state.setAndContinue(IDLE_ANIMATION);
}));
}

In this example:

  • The AnimationController dynamically switches between a walking animation and an idle animation based on whether the entity is moving.

New Approach (Using AzCommand and MoveAnalysis)

With AzCommand, you can dynamically dispatch animation commands based on the entity's state. Instead of continuously managing AnimationController state updates, you simply send the appropriate AzCommand when needed.

To enhance the new AzCommand approach and replace the isMoving logic from the old AnimationController example, you can use the MoveAnalysis utility class. This class provides advanced functionalities, such as detecting horizontal movement and checking whether the entity is on the ground. Here's how you can implement this:

public class ExampleEntity extends Monster {
private final AzCommand idleCommand = AzCommand.create("base", "IDLE_ANIMATION", AzPlayBehaviors.LOOP);
private final AzCommand walkCommand = AzCommand.create("base", "WALK_ANIMATION", AzPlayBehaviors.LOOP);
private final MoveAnalysis moveAnalysis;
public ExampleEntity(EntityType<? extends Monster> entityType, Level level) {
super(entityType, level);
this.moveAnalysis = new MoveAnalysis(this);
}
@Override
public void tick() {
super.tick(); // Update base entity behavior
moveAnalysis.update(); // Analyze the entity's movement state
if (this.level().isClientSide) { // Only execute animation logic on the client
boolean isMovingOnGround = moveAnalysis.isMovingHorizontally() && onGround();
if (isMovingOnGround) {
walkCommand.sendForEntity(this); // Send the walk animation if moving
} else {
idleCommand.sendForEntity(this); // Otherwise, send the idle animation
}
}
}
}
  • One controller: walk and idle replace each other, so they play on the same controller ("base"). Sending the command that's already playing does nothing, so sending every tick is fine.
  • Loop modes: if both animations are set to Loop in Blockbench, AzCommand.create("base", "WALK_ANIMATION") is enough.
  • MoveAnalysis: Provides a reliable method to check if the entity is moving horizontally.
  • Benefits:
    • Improves code readability and reusability by moving movement logic into MoveAnalysis.
    • Cleanly separates movement detection from animation dispatch.