Skip to content

Developers Examples Custom Node

Yanny edited this page Oct 3, 2026 · 2 revisions

Example — a custom node

A complete, minimal plugin that gives a loot entry of your own mod its own node in the loot tree — the server half that is built and sent, and the client half that draws it. Works on every loader. The reference behind every step is Nodes and Widgets and Registration Reference → registerEntry; this page only puts the pieces together.

A node of your own is rarely needed for a single item — an entry that drops one item or tag builds ALI's ItemNode and is done. The usual reason is a composite entry: one that holds other entries and decides how they drop, and whose rule the tree should show. That is the case below.

The entry

Say your mod has an entry that drops all of its children, several times over:

package com.example.mymod.loot;

import com.mojang.serialization.Codec;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.core.Holder;
import net.minecraft.world.level.storage.loot.entries.ComposableEntryContainer;
import net.minecraft.world.level.storage.loot.entries.CompositeEntryBase;
import net.minecraft.world.level.storage.loot.entries.LootPoolEntries;
import net.minecraft.world.level.storage.loot.entries.LootPoolEntryContainer;
import net.minecraft.world.level.storage.loot.functions.LootItemFunction;
import net.minecraft.world.level.storage.loot.predicates.LootItemCondition;

import java.util.List;
import java.util.Optional;

public class RepeatEntry extends CompositeEntryBase {
    public static final MapCodec<RepeatEntry> MAP_CODEC = RecordCodecBuilder.mapCodec((i) -> i.group(
            LootPoolEntries.CODEC.listOf().optionalFieldOf("children", List.of()).forGetter(RepeatEntry::getChildren),
            Codec.INT.fieldOf("times").forGetter(RepeatEntry::getTimes)
    ).and(commonFields(i)).apply(i, RepeatEntry::new));

    private final int times;

    private RepeatEntry(List<LootPoolEntryContainer> children, int times, Optional<Holder<LootItemCondition>> condition,
                        Optional<Holder<LootItemFunction>> modifier) {
        super(children, condition, modifier);
        this.times = times;
    }

    @Override
    public MapCodec<RepeatEntry> codec() {
        return MAP_CODEC;
    }

    @Override
    protected ComposableEntryContainer compose(List<? extends ComposableEntryContainer> children) {
        return (context, output) -> {
            for (int n = 0; n < times; n++) {
                for (ComposableEntryContainer child : children) {
                    child.expand(context, output);
                }
            }

            return true;
        };
    }

    public int getTimes() { return times; }
    public List<LootPoolEntryContainer> getChildren() { return children; }
    public Optional<Holder<LootItemCondition>> getCondition() { return condition; }
    public Optional<Holder<LootItemFunction>> getModifier() { return modifier; }
}

children, condition and modifier are inherited — the entry's own condition and function, each combining several into one. compose is called from the CompositeEntryBase constructor, before times is set; the returned lambda reads times only when the loot is rolled.

The entry type is registered under your mod ID, like any other loot type. On Fabric, call this from your ModInitializer:

package com.example.mymod.loot;

import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;

public class MyModLoot {
    public static void register() {
        Registry.register(BuiltInRegistries.LOOT_POOL_ENTRY_TYPE, "mymod:repeat", RepeatEntry.MAP_CODEC);
    }
}

On NeoForge, call MyModLoot.register(modEventBus) from your mod's constructor:

package com.example.mymod.loot;

import com.mojang.serialization.MapCodec;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.level.storage.loot.entries.LootPoolEntryContainer;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredRegister;

public class MyModLoot {
    private static final DeferredRegister<MapCodec<? extends LootPoolEntryContainer>> ENTRIES =
            DeferredRegister.create(Registries.LOOT_POOL_ENTRY_TYPE, "mymod");

    public static void register(IEventBus modEventBus) {
        ENTRIES.register("repeat", () -> RepeatEntry.MAP_CODEC);
        ENTRIES.register(modEventBus);
    }
}

Forge (1.20.1 and 1.21.1 only) registers the same way with net.minecraftforge.registries.DeferredRegister and net.minecraftforge.eventbus.api.IEventBus; see the version notes below.

26.1.2–26.2: an entry has no modifier, and its conditions are the inherited List<LootItemCondition> conditions. Both MyModLoot variants are the same as above.

RepeatEntry on 26.1.2–26.2
package com.example.mymod.loot;

import com.mojang.serialization.Codec;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.world.level.storage.loot.entries.ComposableEntryContainer;
import net.minecraft.world.level.storage.loot.entries.CompositeEntryBase;
import net.minecraft.world.level.storage.loot.entries.LootPoolEntries;
import net.minecraft.world.level.storage.loot.entries.LootPoolEntryContainer;
import net.minecraft.world.level.storage.loot.predicates.LootItemCondition;

import java.util.List;

public class RepeatEntry extends CompositeEntryBase {
    public static final MapCodec<RepeatEntry> MAP_CODEC = RecordCodecBuilder.mapCodec((i) -> i.group(
            LootPoolEntries.CODEC.listOf().optionalFieldOf("children", List.of()).forGetter(RepeatEntry::getChildren),
            Codec.INT.fieldOf("times").forGetter(RepeatEntry::getTimes)
    ).and(commonFields(i).t1()).apply(i, RepeatEntry::new));

    private final int times;

    private RepeatEntry(List<LootPoolEntryContainer> children, int times, List<LootItemCondition> conditions) {
        super(children, conditions);
        this.times = times;
    }

    @Override
    public MapCodec<RepeatEntry> codec() {
        return MAP_CODEC;
    }

    @Override
    protected ComposableEntryContainer compose(List<? extends ComposableEntryContainer> children) {
        return (context, output) -> {
            for (int n = 0; n < times; n++) {
                for (ComposableEntryContainer child : children) {
                    child.expand(context, output);
                }
            }

            return true;
        };
    }

    public int getTimes() { return times; }
    public List<LootPoolEntryContainer> getChildren() { return children; }
    public List<LootItemCondition> getConditions() { return conditions; }
}

1.21.1–1.21.11: as on 26.1.2–26.2, but the entry returns its registered type from getType() instead of a codec from codec(), so the type object is created in MyModLoot and registered there.

RepeatEntry and MyModLoot on 1.21.1–1.21.11
package com.example.mymod.loot;

import com.mojang.serialization.Codec;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.world.level.storage.loot.entries.ComposableEntryContainer;
import net.minecraft.world.level.storage.loot.entries.CompositeEntryBase;
import net.minecraft.world.level.storage.loot.entries.LootPoolEntries;
import net.minecraft.world.level.storage.loot.entries.LootPoolEntryContainer;
import net.minecraft.world.level.storage.loot.entries.LootPoolEntryType;
import net.minecraft.world.level.storage.loot.predicates.LootItemCondition;

import java.util.List;

public class RepeatEntry extends CompositeEntryBase {
    public static final MapCodec<RepeatEntry> MAP_CODEC = RecordCodecBuilder.mapCodec((i) -> i.group(
            LootPoolEntries.CODEC.listOf().optionalFieldOf("children", List.of()).forGetter(RepeatEntry::getChildren),
            Codec.INT.fieldOf("times").forGetter(RepeatEntry::getTimes)
    ).and(commonFields(i).t1()).apply(i, RepeatEntry::new));

    private final int times;

    private RepeatEntry(List<LootPoolEntryContainer> children, int times, List<LootItemCondition> conditions) {
        super(children, conditions);
        this.times = times;
    }

    @Override
    public LootPoolEntryType getType() {
        return MyModLoot.REPEAT;
    }

    @Override
    protected ComposableEntryContainer compose(List<? extends ComposableEntryContainer> children) {
        return (context, output) -> {
            for (int n = 0; n < times; n++) {
                for (ComposableEntryContainer child : children) {
                    child.expand(context, output);
                }
            }

            return true;
        };
    }

    public int getTimes() { return times; }
    public List<LootPoolEntryContainer> getChildren() { return children; }
    public List<LootItemCondition> getConditions() { return conditions; }
}

Fabric:

package com.example.mymod.loot;

import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.world.level.storage.loot.entries.LootPoolEntryType;

public class MyModLoot {
    public static final LootPoolEntryType REPEAT = new LootPoolEntryType(RepeatEntry.MAP_CODEC);

    public static void register() {
        Registry.register(BuiltInRegistries.LOOT_POOL_ENTRY_TYPE, "mymod:repeat", REPEAT);
    }
}

NeoForge, and Forge on 1.21.1 with the Forge packages named above:

package com.example.mymod.loot;

import net.minecraft.core.registries.Registries;
import net.minecraft.world.level.storage.loot.entries.LootPoolEntryType;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredRegister;

public class MyModLoot {
    public static final LootPoolEntryType REPEAT = new LootPoolEntryType(RepeatEntry.MAP_CODEC);

    private static final DeferredRegister<LootPoolEntryType> ENTRIES =
            DeferredRegister.create(Registries.LOOT_POOL_ENTRY_TYPE, "mymod");

    public static void register(IEventBus modEventBus) {
        ENTRIES.register("repeat", () -> REPEAT);
        ENTRIES.register(modEventBus);
    }
}

On 1.20.1 the entry is read from JSON by a Serializer instead of a codec, and its children and conditions are arrays — the getters below return them as lists, which is what the later steps expect.

RepeatEntry and MyModLoot on 1.20.1
package com.example.mymod.loot;

import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonObject;
import com.google.gson.JsonSerializationContext;
import net.minecraft.util.GsonHelper;
import net.minecraft.world.level.storage.loot.entries.ComposableEntryContainer;
import net.minecraft.world.level.storage.loot.entries.CompositeEntryBase;
import net.minecraft.world.level.storage.loot.entries.LootPoolEntryContainer;
import net.minecraft.world.level.storage.loot.entries.LootPoolEntryType;
import net.minecraft.world.level.storage.loot.predicates.LootItemCondition;

import java.util.List;

public class RepeatEntry extends CompositeEntryBase {
    private final int times;

    public RepeatEntry(LootPoolEntryContainer[] children, LootItemCondition[] conditions, int times) {
        super(children, conditions);
        this.times = times;
    }

    @Override
    public LootPoolEntryType getType() {
        return MyModLoot.REPEAT;
    }

    @Override
    protected ComposableEntryContainer compose(ComposableEntryContainer[] children) {
        return (context, output) -> {
            for (int n = 0; n < times; n++) {
                for (ComposableEntryContainer child : children) {
                    child.expand(context, output);
                }
            }

            return true;
        };
    }

    public int getTimes() { return times; }
    public List<LootPoolEntryContainer> getChildren() { return List.of(children); }
    public List<LootItemCondition> getConditions() { return List.of(conditions); }

    public static class Serializer extends LootPoolEntryContainer.Serializer<RepeatEntry> {
        @Override
        public void serializeCustom(JsonObject json, RepeatEntry entry, JsonSerializationContext context) {
            json.add("children", context.serialize(entry.children));
            json.addProperty("times", entry.times);
        }

        @Override
        public RepeatEntry deserializeCustom(JsonObject json, JsonDeserializationContext context,
                                             LootItemCondition[] conditions) {
            LootPoolEntryContainer[] children = GsonHelper.getAsObject(json, "children", context, LootPoolEntryContainer[].class);
            return new RepeatEntry(children, conditions, GsonHelper.getAsInt(json, "times"));
        }
    }
}

Fabric:

package com.example.mymod.loot;

import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.world.level.storage.loot.entries.LootPoolEntryType;

public class MyModLoot {
    public static final LootPoolEntryType REPEAT = new LootPoolEntryType(new RepeatEntry.Serializer());

    public static void register() {
        Registry.register(BuiltInRegistries.LOOT_POOL_ENTRY_TYPE, "mymod:repeat", REPEAT);
    }
}

Forge:

package com.example.mymod.loot;

import net.minecraft.core.registries.Registries;
import net.minecraft.world.level.storage.loot.entries.LootPoolEntryType;
import net.minecraftforge.eventbus.api.IEventBus;
import net.minecraftforge.registries.DeferredRegister;

public class MyModLoot {
    public static final LootPoolEntryType REPEAT = new LootPoolEntryType(new RepeatEntry.Serializer());

    private static final DeferredRegister<LootPoolEntryType> ENTRIES =
            DeferredRegister.create(Registries.LOOT_POOL_ENTRY_TYPE, "mymod");

    public static void register(IEventBus modEventBus) {
        ENTRIES.register("repeat", () -> REPEAT);
        ENTRIES.register(modEventBus);
    }
}

Up to 26.1.2 vanilla's ComposableEntryContainer, the type compose returns, is not public, so the entry only compiles once your mod widens it. On Fabric, in the access widener that fabric.mod.json names under accessWidener (its header ends in official on 26.1.2 and in named before):

accessWidener v2 named
accessible class net/minecraft/world/level/storage/loot/entries/ComposableEntryContainer

On Forge and NeoForge, in META-INF/accesstransformer.cfg:

public net.minecraft.world.level.storage.loot.entries.ComposableEntryContainer

Because it extends vanilla's CompositeEntryBase, ALI already knows its weight and its children — that is enough for the loot table to be scanned and for references inside it to be followed. Only the node is missing: without one, the entry is shown as an unsupported entry, with nothing more than its JSON.

An entry that does not extend CompositeEntryBase registers the two itself, with registerEntryWeight and registerEntryChildren.

Step 1 — the dependency and the translation keys

Compile against ALI and ACI and keep both optional at runtime, as described in Getting Started → Depending on ALI.

The node's tooltip needs two keys. Declare and translate them exactly as in Example: loot conditions and functions → translation keys:

REPEAT("mymod.ali.entry.repeat", "Repeat:"),
TIMES("mymod.ali.value.times", "Times: %s"),

in the MyLang enum, and in assets/mymod/lang/en_us.json:

{
  "mymod.ali.entry.repeat": "Repeat:",
  "mymod.ali.value.times": "Times: %s"
}

Step 2 — the node (both sides)

package com.example.mymod.ali;

import com.yanny.aci.tooltip.TooltipNode;
import com.yanny.ali.api.IClientUtils;
import com.yanny.ali.api.IServerUtils;
import com.yanny.ali.api.ListNode;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.resources.Identifier;

public class RepeatNode extends ListNode {
    public static final Identifier ID = Identifier.fromNamespaceAndPath("mymod", "repeat");

    private final int times;
    private final TooltipNode tooltip;

    public RepeatNode(int times, TooltipNode tooltip) {
        this.times = times;
        this.tooltip = tooltip;
    }

    public RepeatNode(IClientUtils utils, RegistryFriendlyByteBuf buf) {
        super(utils, buf);
        times = buf.readVarInt();
        tooltip = utils.getTooltipCache().getNodeById(buf.readVarInt());
    }

    @Override
    public void encodeNode(IServerUtils utils, RegistryFriendlyByteBuf buf) {
        buf.writeVarInt(times);
        buf.writeVarInt(utils.getTooltipCache().getNodeId(tooltip));
    }

    public int getTimes() {
        return times;
    }

    @Override
    public TooltipNode getTooltip() {
        return tooltip;
    }

    @Override
    public Identifier getId() {
        return ID;
    }
}
  • The first constructor is the server's, the second the client's: it gets back exactly what was sent.
  • ListNode writes and reads the children itself, before your own data. So super(utils, buf) comes first in the client constructor, and encodeNode writes your fields in the same order the client constructor reads them — a mismatch corrupts everything after it in the buffer, not only this node.
  • The tooltip is not sent inline: getTooltipCache() turns it into an index on the server and back on the client.
  • ID pairs the node with its widget; keep it under your own namespace.

Up to 1.21.1 Identifier is ResourceLocation, created with ResourceLocation.fromNamespaceAndPath on 1.21.1 and new ResourceLocation on 1.20.1, where the buffer is also a plain FriendlyByteBuf.

RepeatNode on 1.21.1
package com.example.mymod.ali;

import com.yanny.aci.tooltip.TooltipNode;
import com.yanny.ali.api.IClientUtils;
import com.yanny.ali.api.IServerUtils;
import com.yanny.ali.api.ListNode;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;

public class RepeatNode extends ListNode {
    public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath("mymod", "repeat");

    private final int times;
    private final TooltipNode tooltip;

    public RepeatNode(int times, TooltipNode tooltip) {
        this.times = times;
        this.tooltip = tooltip;
    }

    public RepeatNode(IClientUtils utils, RegistryFriendlyByteBuf buf) {
        super(utils, buf);
        times = buf.readVarInt();
        tooltip = utils.getTooltipCache().getNodeById(buf.readVarInt());
    }

    @Override
    public void encodeNode(IServerUtils utils, RegistryFriendlyByteBuf buf) {
        buf.writeVarInt(times);
        buf.writeVarInt(utils.getTooltipCache().getNodeId(tooltip));
    }

    public int getTimes() {
        return times;
    }

    @Override
    public TooltipNode getTooltip() {
        return tooltip;
    }

    @Override
    public ResourceLocation getId() {
        return ID;
    }
}
RepeatNode on 1.20.1
package com.example.mymod.ali;

import com.yanny.aci.tooltip.TooltipNode;
import com.yanny.ali.api.IClientUtils;
import com.yanny.ali.api.IServerUtils;
import com.yanny.ali.api.ListNode;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;

public class RepeatNode extends ListNode {
    public static final ResourceLocation ID = new ResourceLocation("mymod", "repeat");

    private final int times;
    private final TooltipNode tooltip;

    public RepeatNode(int times, TooltipNode tooltip) {
        this.times = times;
        this.tooltip = tooltip;
    }

    public RepeatNode(IClientUtils utils, FriendlyByteBuf buf) {
        super(utils, buf);
        times = buf.readVarInt();
        tooltip = utils.getTooltipCache().getNodeById(buf.readVarInt());
    }

    @Override
    public void encodeNode(IServerUtils utils, FriendlyByteBuf buf) {
        buf.writeVarInt(times);
        buf.writeVarInt(utils.getTooltipCache().getNodeId(tooltip));
    }

    public int getTimes() {
        return times;
    }

    @Override
    public TooltipNode getTooltip() {
        return tooltip;
    }

    @Override
    public ResourceLocation getId() {
        return ID;
    }
}

Step 3 — building the node from the entry

package com.example.mymod.ali;

import com.example.mymod.loot.RepeatEntry;
import com.yanny.aci.api.NumberExpr;
import com.yanny.aci.tooltip.TooltipBuilder;
import com.yanny.aci.tooltip.TooltipNode;
import com.yanny.ali.api.IDataNode;
import com.yanny.ali.api.IServerUtils;
import net.minecraft.core.Holder;
import net.minecraft.world.level.storage.loot.entries.LootPoolEntryContainer;
import net.minecraft.world.level.storage.loot.functions.LootItemFunction;
import net.minecraft.world.level.storage.loot.predicates.LootItemCondition;

import java.util.List;
import java.util.stream.Stream;

public class MyModNodes {
    public static IDataNode getRepeatNode(IServerUtils utils, RepeatEntry entry, NumberExpr chance, NumberExpr sumWeight,
                                          List<TooltipNode> chanceConditions, List<LootItemFunction> functions,
                                          List<LootItemCondition> conditions) {
        List<LootItemCondition> allConditions = Stream.concat(conditions.stream(), entry.getCondition().stream()
                .filter(Holder::isBound).flatMap((c) -> utils.unwrapCondition(utils, c.value()).stream())).toList();
        List<LootItemFunction> allFunctions = Stream.concat(functions.stream(), entry.getModifier().stream()
                .filter(Holder::isBound).flatMap((f) -> utils.unwrapFunction(utils, f.value()).stream())).toList();
        List<TooltipNode> sharedChanceConditions = List.copyOf(chanceConditions);
        TooltipNode tooltip = TooltipBuilder.array((b) -> {
            b.add(utils.getValueTooltip(utils, entry.getTimes()).build(MyLang.TIMES));
        }, MyLang.REPEAT).build();
        RepeatNode node = new RepeatNode(entry.getTimes(), tooltip);

        for (LootPoolEntryContainer child : entry.getChildren()) {
            node.addChildren(utils.getEntryFactory(utils, child)
                    .create(utils, child, chance, sumWeight, sharedChanceConditions, allFunctions, allConditions));
        }

        return node;
    }
}

Up to 26.2 the entry has no function of its own, so functions are passed on unchanged, and its conditions are already a list (the 1.20.1 getter turns the array into one):

package com.example.mymod.ali;

import com.example.mymod.loot.RepeatEntry;
import com.yanny.aci.api.NumberExpr;
import com.yanny.aci.tooltip.TooltipBuilder;
import com.yanny.aci.tooltip.TooltipNode;
import com.yanny.ali.api.IDataNode;
import com.yanny.ali.api.IServerUtils;
import net.minecraft.world.level.storage.loot.entries.LootPoolEntryContainer;
import net.minecraft.world.level.storage.loot.functions.LootItemFunction;
import net.minecraft.world.level.storage.loot.predicates.LootItemCondition;

import java.util.List;
import java.util.stream.Stream;

public class MyModNodes {
    public static IDataNode getRepeatNode(IServerUtils utils, RepeatEntry entry, NumberExpr chance, NumberExpr sumWeight,
                                          List<TooltipNode> chanceConditions, List<LootItemFunction> functions,
                                          List<LootItemCondition> conditions) {
        List<LootItemCondition> allConditions = Stream.concat(conditions.stream(), entry.getConditions().stream()).toList();
        List<TooltipNode> sharedChanceConditions = List.copyOf(chanceConditions);
        TooltipNode tooltip = TooltipBuilder.array((b) -> {
            b.add(utils.getValueTooltip(utils, entry.getTimes()).build(MyLang.TIMES));
        }, MyLang.REPEAT).build();
        RepeatNode node = new RepeatNode(entry.getTimes(), tooltip);

        for (LootPoolEntryContainer child : entry.getChildren()) {
            node.addChildren(utils.getEntryFactory(utils, child)
                    .create(utils, child, chance, sumWeight, sharedChanceConditions, functions, allConditions));
        }

        return node;
    }
}
  • The factory gets what the pool knows about this spot: the chance and the total weight of the pool, the conditions behind the chance, and the functions and conditions inherited from above. A composite passes them on to its children, adding its own condition and function — exactly what ALI's own group entry does. utils.unwrapCondition / unwrapFunction split a combined all_of / sequence into the list ALI works with.
  • utils.getEntryFactory(utils, child) builds each child with whatever is registered for it — vanilla items, tags, nested composites, other mods' entries — so your node never needs to know what its children are.
  • The children keep their own counts; the node only states the rule (Repeat: Times: 3). Anything that has to be computed on the client, such as the multiplied count, would go into the node's own data instead.

Register it, and the node's client half, in the plugin:

package com.example.mymod.ali;

import com.example.mymod.loot.RepeatEntry;
import com.yanny.ali.api.AliEntrypoint;
import com.yanny.ali.api.IClientRegistry;
import com.yanny.ali.api.ICommonRegistry;
import com.yanny.ali.api.IPlugin;
import com.yanny.ali.api.IServerRegistry;

@AliEntrypoint
public class MyModAliPlugin implements IPlugin {
    @Override
    public String getModId() {
        return "mymod";
    }

    @Override
    public void registerCommon(ICommonRegistry registry) {
        for (MyLang key : MyLang.values()) {
            registry.registerTranslationKey(key.singular());
        }
    }

    @Override
    public void registerClient(IClientRegistry registry) {
        registry.registerDataNode(RepeatNode.ID, RepeatNode::new);
        registry.registerWidget(RepeatNode.ID, RepeatWidget::new);
    }

    @Override
    public void registerServer(IServerRegistry registry) {
        registry.registerEntry(RepeatEntry.class, MyModNodes::getRepeatNode);
    }
}

On Fabric list the class under the ali entrypoint in fabric.mod.json as well — see Making ALI find the plugin. registerClient only runs on the client, so a dedicated server never loads the widget class.

Step 4 — the widget

package com.example.mymod.ali;

import com.yanny.aci.api.IWidget;
import com.yanny.aci.api.RelativeRect;
import com.yanny.ali.api.IDataNode;
import com.yanny.ali.api.IWidgetUtils;
import com.yanny.ali.api.ListWidget;
import com.yanny.ali.plugin.client.widget.TextureWidget;
import net.minecraft.resources.Identifier;

public class RepeatWidget extends ListWidget {
    private static final Identifier ICON = Identifier.fromNamespaceAndPath("mymod", "textures/gui/repeat.png");

    public RepeatWidget(IWidgetUtils utils, IDataNode entry, RelativeRect rect, int maxWidth) {
        super(utils, entry, rect, maxWidth);
    }

    @Override
    public IWidget getGroupWidget(RelativeRect rect, IDataNode entry) {
        TextureWidget icon = new TextureWidget(ICON, new RelativeRect(0, 0, GROUP_WIDGET_WIDTH, GROUP_WIDGET_HEIGHT, rect),
                0, 0, GROUP_WIDGET_WIDTH, GROUP_WIDGET_HEIGHT, GROUP_WIDGET_WIDTH, GROUP_WIDGET_HEIGHT);

        icon.tooltipText(entry.getTooltip());
        return icon;
    }
}

Up to 1.21.1 the icon is a ResourceLocation; the class is otherwise the same.

RepeatWidget on 1.21.1
package com.example.mymod.ali;

import com.yanny.aci.api.IWidget;
import com.yanny.aci.api.RelativeRect;
import com.yanny.ali.api.IDataNode;
import com.yanny.ali.api.IWidgetUtils;
import com.yanny.ali.api.ListWidget;
import com.yanny.ali.plugin.client.widget.TextureWidget;
import net.minecraft.resources.ResourceLocation;

public class RepeatWidget extends ListWidget {
    private static final ResourceLocation ICON = ResourceLocation.fromNamespaceAndPath("mymod", "textures/gui/repeat.png");

    public RepeatWidget(IWidgetUtils utils, IDataNode entry, RelativeRect rect, int maxWidth) {
        super(utils, entry, rect, maxWidth);
    }

    @Override
    public IWidget getGroupWidget(RelativeRect rect, IDataNode entry) {
        TextureWidget icon = new TextureWidget(ICON, new RelativeRect(0, 0, GROUP_WIDGET_WIDTH, GROUP_WIDGET_HEIGHT, rect),
                0, 0, GROUP_WIDGET_WIDTH, GROUP_WIDGET_HEIGHT, GROUP_WIDGET_WIDTH, GROUP_WIDGET_HEIGHT);

        icon.tooltipText(entry.getTooltip());
        return icon;
    }
}
RepeatWidget on 1.20.1
package com.example.mymod.ali;

import com.yanny.aci.api.IWidget;
import com.yanny.aci.api.RelativeRect;
import com.yanny.ali.api.IDataNode;
import com.yanny.ali.api.IWidgetUtils;
import com.yanny.ali.api.ListWidget;
import com.yanny.ali.plugin.client.widget.TextureWidget;
import net.minecraft.resources.ResourceLocation;

public class RepeatWidget extends ListWidget {
    private static final ResourceLocation ICON = new ResourceLocation("mymod", "textures/gui/repeat.png");

    public RepeatWidget(IWidgetUtils utils, IDataNode entry, RelativeRect rect, int maxWidth) {
        super(utils, entry, rect, maxWidth);
    }

    @Override
    public IWidget getGroupWidget(RelativeRect rect, IDataNode entry) {
        TextureWidget icon = new TextureWidget(ICON, new RelativeRect(0, 0, GROUP_WIDGET_WIDTH, GROUP_WIDGET_HEIGHT, rect),
                0, 0, GROUP_WIDGET_WIDTH, GROUP_WIDGET_HEIGHT, GROUP_WIDGET_WIDTH, GROUP_WIDGET_HEIGHT);

        icon.tooltipText(entry.getTooltip());
        return icon;
    }
}
  • ListWidget lays the children out to the right and draws the branch lines; you only supply the group icon on the left, the one the player hovers to read the node's tooltip. It is a 7 × 18 pixel image, assets/mymod/textures/gui/repeat.png.
  • getGroupWidget is called from inside the ListWidget constructor, before your own constructor body runs — use only its arguments and static fields there.
  • The same widget is used in EMI, JEI and REI; nothing viewer-specific is needed.

To reuse one of ALI's own group icons instead of drawing one, return WidgetUtils.getAllWidget(rect, entry) (the all of icon) — or skip the widget class altogether and register GroupWidget::new for your ID, which draws exactly that. WidgetUtils lives in com.yanny.ali.plugin.client, GroupWidget in com.yanny.ali.plugin.client.widget, like TextureWidget.

Step 5 — check it

Start the game with ALI and a recipe viewer and open a loot table that uses your entry: its children are drawn as a branch behind your icon, and hovering the icon shows Repeat: with Times:. If the client logs node {mymod:repeat} was not registered, registerDataNode is missing or registered under a different ID than the node's getId().

Questions: Discord.

Clone this wiki locally