Skip to content

Custom Block Animations

Ethan Leitner edited this page Feb 22, 2026 · 3 revisions

Block Animations

This page covers implementing animated blocks using Hytale Model Loader. The system has three moving parts: the block class, the block entity, and the renderer. The chest is used as the example throughout.

Overview

  • The block class handles player interaction and drives the ticker
  • The block entity tracks animation state and syncs it to clients
  • The renderer reads that state and samples the correct animation file each frame

1. The Block Class

Your block must implement EntityBlock and extend HytaleBlockBase. Two things are required beyond a normal block: a ticker that drives the animation clock, and RenderShape.INVISIBLE so Minecraft doesn't try to render a vanilla model on top.

public class HytaleChest extends HytaleBlockBase implements EntityBlock {

    public HytaleChest(Properties properties) {
        super(properties);
    }

    @Override
    public @Nullable BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
        return new AnimatedChestBlockEntity(pos, state);
    }

    // Drive the animation tick on the client
    @Override
    public @Nullable <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state,
                                                                             BlockEntityType<T> type) {
        if (!level.isClientSide()) return null;
        return (lvl, pos, blockState, blockEntity) -> {
            if (blockEntity instanceof AnimatedChestBlockEntity chest) {
                AnimatedChestBlockEntity.clientTick(lvl, pos, blockState, chest);
            }
        };
    }

    // Required — prevents Minecraft rendering a vanilla model
    @Override
    protected RenderShape getRenderShape(BlockState state) {
        return RenderShape.INVISIBLE;
    }

    // Trigger state changes however your block needs to — here it's on click
    @Override
    protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
                                               Player player, BlockHitResult hitResult) {
        if (level.isClientSide()) {
            BlockEntity entity = level.getBlockEntity(pos);
            if (entity instanceof AnimatedChestBlockEntity chest) {
                if (chest.isOpen()) chest.closeChest();
                else chest.openChest();
            }
        }
        return InteractionResult.SUCCESS;
    }
}

2. The Block Entity

Your block entity extends HytaleBlockEntity and is responsible for three things: tracking the animation tick, tracking your custom state (e.g. open/closed), and syncing that state to clients when it changes.

public class AnimatedChestBlockEntity extends HytaleBlockEntity {

    private static final String NBT_KEY = "ChestData";
    private static final String NBT_IS_OPEN = "IsOpen";

    private boolean isOpen = false;
    private int animationTick = 0;
    private boolean lastKnownOpen = false;

    public AnimatedChestBlockEntity(BlockPos pos, BlockState state) {
        // The last argument must match the model name used in your block model JSON
        super(BlockEntityInit.CHEST_TEST_ENT.get(), pos, state, "chest_small");
    }

    // Called by the ticker — increments the clock the renderer samples against
    public static void clientTick(Level level, BlockPos pos, BlockState state,
                                  AnimatedChestBlockEntity be) {
        be.animationTick++;
    }

    @Override
    public int getAnimationTick() {
        return animationTick;
    }

    // State change methods — reset the tick and sync to clients for swapping animations
    public void openChest() {
        if (!isOpen) {
            animationTick = 0;
            isOpen = true;
            setChanged();
            syncToClients();
        }
    }

    public void closeChest() {
        if (isOpen) {
            animationTick = 0;
            isOpen = false;
            setChanged();
            syncToClients();
        }
    }

    public boolean isOpen() { return isOpen; }

    // Save your custom state to NBT
    @Override
    protected void saveAnimationData(ValueOutput output) {
        CompoundTag tag = new CompoundTag();
        tag.putBoolean(NBT_IS_OPEN, isOpen);
        output.store(NBT_KEY, CompoundTag.CODEC, tag);
    }

    // Load it back — reset the tick if state changed since last sync
    @Override
    protected void loadAnimationData(ValueInput input) {
        input.read(NBT_KEY, CompoundTag.CODEC).ifPresent(tag -> {
            boolean newOpen = tag.getBoolean(NBT_IS_OPEN).orElse(false);
            if (newOpen != lastKnownOpen) {
                animationTick = 0;
                lastKnownOpen = newOpen;
            }
            isOpen = newOpen;
        });
    }

    private void syncToClients() {
        if (level != null && !level.isClientSide()) {
            level.blockEntityChanged(getBlockPos());
        }
    }
}

Note: The animation tick is reset to 0 whenever state changes. This ensures the animation always plays from the beginning when transitioning between open and closed.


3. The Render State

The render state is a simple data class that carries everything the renderer needs from the block entity. It extends HytaleRenderState and adds whatever custom fields your animation logic requires.

public class AnimatedChestRenderState extends HytaleRenderState {
    public boolean isOpen;
}

4. The Renderer

Your renderer extends HytaleBlockEntityRenderer typed to your block entity and render state. There are two methods to implement: extractAdditionalRenderState copies data from the block entity into the render state, and calculateAnimationTransforms uses that state to sample the correct animation file.

public class AnimatedChestRenderer extends HytaleBlockEntityRenderer<AnimatedChestBlockEntity, AnimatedChestRenderState> {

    private static final Identifier ANIM_OPEN =
            Identifier.fromNamespaceAndPath(HytaleModelLoader.MODID,
                    "animations/chest_small/chest_open.blockyanim");

    private static final Identifier ANIM_CLOSE =
            Identifier.fromNamespaceAndPath(HytaleModelLoader.MODID,
                    "animations/chest_small/chest_close.blockyanim");

    public AnimatedChestRenderer(BlockEntityRendererProvider.Context context) {
        super(context);
    }

    @Override
    public AnimatedChestRenderState createRenderState() {
        return new AnimatedChestRenderState();
    }

    // Copy what you need from the block entity into the render state
    @Override
    protected void extractAdditionalRenderState(AnimatedChestBlockEntity blockEntity,
                                                AnimatedChestRenderState renderState,
                                                float partialTick) {
        renderState.isOpen = blockEntity.isOpen();
    }

    // Pick the right animation and sample it at the current tick
    @Override
    protected Map<String, NodeTransform> calculateAnimationTransforms(AnimatedChestRenderState renderState,
                                                                      BlockyModelGeometry geometry) {
        Map<String, NodeTransform> transforms = new HashMap<>();

        Identifier animId = renderState.isOpen ? ANIM_OPEN : ANIM_CLOSE;
        BlockyAnimationPlayer animationPlayer = getOrCreatePlayer(animId);
        if (animationPlayer != null) {
            transforms.putAll(animationPlayer.calculateTransforms(renderState.ageInTicks));
        }

        return transforms;
    }
}

Animation files are loaded by identifier and sampled using ageInTicks, which comes from HytaleRenderState and is automatically populated from your block entity's getAnimationTick().

Animation file location:

assets/<modid>/animations/<model_name>/your_animation.blockyanim

5. Registration

Register your block entity in your BlockEntityInit and your renderer in your client setup. Don't forget to also register your block and block item.

// BlockEntityInit.java
public static final Supplier<BlockEntityType<AnimatedChestBlockEntity>> CHEST_TEST_ENT = BLOCK_ENTITIES.register(
            "hytale_chest", () -> new BlockEntityType<>(
                    AnimatedChestBlockEntity::new,
                    false,
                    BlockInit.SMALL_CHEST.get()
            )
    );
public static final DeferredBlock<Block> SMALL_CHEST = BLOCKS.registerBlock("chest_small", HytaleChest::new);
public static final DeferredItem<BlockItem> SMALL_CHEST = ITEMS.registerSimpleBlockItem("chest_small", BlockInit.SMALL_CHEST);
// Client setup — wherever you register your other block entity renderers
BlockEntityRenderers.register(BlockEntityInit.CHEST_TEST_ENT.get(), AnimatedChestRenderer::new);

Procedural Animations

Instead of (or alongside) a .blockyanim file, you can drive bone transforms directly in calculateAnimationTransforms using NodeTransform. The key is the string you pass to transforms.put() — it must exactly match the bone name in your .blockymodel file.

NodeTransform has four factory methods:

NodeTransform.translation(new Vector3f(x, y, z));        // Move a bone
NodeTransform.rotation(new Quaternionf().rotateX(angle)); // Rotate a bone
NodeTransform.scale(new Vector3f(x, y, z));              // Scale a bone
NodeTransform.visibility(false);                          // Show/hide a bone

Example — Sinusoidal Lid Rotation

@Override
protected Map<String, NodeTransform> calculateAnimationTransforms(AnimatedChestRenderState renderState,
                                                                   BlockyModelGeometry geometry) {
    Map<String, NodeTransform> transforms = new HashMap<>();

    float angle = (float) Math.sin(renderState.ageInTicks * ANIMATION_SPEED) * MAX_LID_ANGLE;
    Quaternionf rotation = new Quaternionf().rotateX((float) Math.toRadians(-Math.abs(angle)));
    transforms.put("Lid", NodeTransform.rotation(rotation));

    return transforms;
}

ageInTicks increments every client tick, so passing it into a Math.sin() produces a smooth repeating motion. Multiply by a small speed constant to control how fast it oscillates, and by a max angle to control how far it rotates.

Combining Procedural and File-Based Animations

You can mix both systems in the same method. Load the animation file first, then put any procedural transforms on top. They will overwrite the file-based transform for that specific bone while leaving all others untouched.

@Override
protected Map<String, NodeTransform> calculateAnimationTransforms(AnimatedChestRenderState renderState,
                                                                   BlockyModelGeometry geometry) {
    Map<String, NodeTransform> transforms = new HashMap<>();

    // File-based animation drives most bones
    BlockyAnimationPlayer animationPlayer = getOrCreatePlayer(animId);
    if (animationPlayer != null) {
        transforms.putAll(animationPlayer.calculateTransforms(renderState.ageInTicks));
    }

    // Procedural transform overrides a specific bone on top
    float angle = (float) Math.sin(renderState.ageInTicks * 0.05f) * 15f;
    transforms.put("Lid", NodeTransform.rotation(
        new Quaternionf().rotateX((float) Math.toRadians(-Math.abs(angle)))
    ));

    return transforms;
}

Merging Transforms

If you want to combine two transforms on the same bone additively rather than overwrite one with the other, use NodeTransform.merge():

NodeTransform fileTransform = transforms.get("Lid");
NodeTransform proceduralTransform = NodeTransform.translation(new Vector3f(0, 0.1f, 0));

if (fileTransform != null) {
    transforms.put("Lid", fileTransform.merge(proceduralTransform));
}

merge() adds positions, multiplies rotations, multiplies scales, and ANDs visibility, so both transforms contribute to the final result.

Note: When making procedural animations remember that all child nodes will be transformed attached to their parent node.