Skip to content

Developers Loot Info Nodes And Widgets

Yanny edited this page Oct 9, 2026 · 4 revisions

Nodes and Widgets

The loot tree is made of data nodes. A node is built on the server, serialized, and reconstructed and rendered on the client by a matching widget. You only need your own node type when your content does not fit any existing one — for plain item drops, reuse ALI's ItemNode. A complete composite node, from the entry to the widget, is walked through in Example: a custom node.

The contract

A node implements com.yanny.ali.api.IDataNode:

Member Purpose
Identifier getId() the node type ID; the key that pairs the node with its widget
TooltipNode getTooltip() the tooltip tree of this node
void encode(IServerUtils utils, RegistryFriendlyByteBuf buf) writes the node to the network buffer
float getChance() (default 1) used to sort siblings by descending chance

Every node has two constructors by convention:

  • a server constructor that takes the domain objects and builds the tooltip,
  • a client constructor (IClientUtils utils, RegistryFriendlyByteBuf buf) that reads back exactly what encode wrote.

Tooltips are not sent inline — they are interned and referenced by ID:

public class MyNode implements IDataNode {
    public static final Identifier ID = Identifier.fromNamespaceAndPath("mymod", "my_node");

    private final TooltipNode tooltip;
    private final float chance;

    public MyNode(float chance, TooltipNode tooltip) {
        this.chance = chance;
        this.tooltip = tooltip;
    }

    public MyNode(IClientUtils utils, RegistryFriendlyByteBuf buf) {
        this.tooltip = utils.getTooltipCache().getNodeById(buf.readVarInt());
        this.chance = buf.readFloat();
    }

    @Override
    public void encode(IServerUtils utils, RegistryFriendlyByteBuf buf) {
        buf.writeVarInt(utils.getTooltipCache().getNodeId(tooltip));
        buf.writeFloat(chance);
    }

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

    @Override
    public @NotNull Identifier getId() {
        return ID;
    }

    @Override
    public float getChance() {
        return chance;
    }
}

On 1.21.1: ResourceLocation instead of Identifier, created with ResourceLocation.fromNamespaceAndPath("mymod", "my_node").

On 1.20.1: the ID is a ResourceLocation built with its constructor, and the buffer is a plain FriendlyByteBuf — in encode, in the client constructor and in registerDataNode:

public class MyNode implements IDataNode {
    public static final ResourceLocation ID = new ResourceLocation("mymod", "my_node");

    private final TooltipNode tooltip;
    private final float chance;

    public MyNode(float chance, TooltipNode tooltip) {
        this.chance = chance;
        this.tooltip = tooltip;
    }

    public MyNode(IClientUtils utils, FriendlyByteBuf buf) {
        this.tooltip = utils.getTooltipCache().getNodeById(buf.readVarInt());
        this.chance = buf.readFloat();
    }

    @Override
    public void encode(IServerUtils utils, FriendlyByteBuf buf) {
        buf.writeVarInt(utils.getTooltipCache().getNodeId(tooltip));
        buf.writeFloat(chance);
    }

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

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

    @Override
    public float getChance() {
        return chance;
    }
}

encode and the client constructor must stay in exact sync. A mismatch corrupts everything that follows in the buffer, not just your node.

Nodes with children

For a node that contains other nodes (alternatives, groups, sequences, …), extend com.yanny.ali.api.ListNode instead of implementing IDataNode directly. It handles the child list, sorting and the child part of the encoding; you add your own payload in encodeNode, and read it back in the client constructor after calling super.

The client sorts the children by descending chance. A node whose children are positional — the first child means something different from the second, like the cost and result slots of a trade — overrides isOrdered() to return true, and its children arrive in the order they were added. It is called from the decoding constructor, before any field of your own is set, so it returns a constant.

Nodes that stand for items

A recipe viewer needs flat item lists next to the rendered tree: the recipe's outputs, the two sides of a trade, and the reverse index that answers which loot table drops this item. ALI does not receive those lists from the server — it reads them out of the tree — so a node holding an item has to say so.

A leaf holding one item (or one tag) implements com.yanny.ali.api.IItemNode:

Member Purpose
Either<ItemStack, TagKey<? extends ItemLike>> getItem() what the slot stands for: one stack, or the tag it picks from
List<ItemStack> getItems() the stacks it resolves to — a tag's members, expanded against the client's registry
void retainItems(Predicate<ItemStack> isVisible) (default: nothing) drop the members the viewer hides
List<LootItemCondition> getConditions() the conditions of the entry
List<LootItemFunction> getFunctions() the functions of the entry
NumberExpr getCount() the dropped count, used for the count shown on the slot
float getChance() the drop chance
boolean hasPredicates() whether the entry is gated by conditions other than plain drop chance

Resolve the tag in the constructor, into a mutable list, and keep getItems() returning that same list — this is what lets retainItems narrow it and what makes a tag follow datapack reloads. NodeUtils.resolveItems does it for you. An empty stack stands for nothing and must not be reported.

Reusing ItemNode gives you all of this. Only a node that holds an item in some shape of its own needs to implement the interface.

A trade additionally implements com.yanny.ali.api.ITradeNode (getInputItems, getOutputItems). A leaf cannot answer this — an item node is one stack and does not know whether it was a cost or a result — so the node that holds the whole trade splits its own children. Built-in ItemsToItemsNode already does.

The widget

The client side of the pair renders the node. Both halves are registered in registerClient under the same ID:

@Override
public void registerClient(IClientRegistry registry) {
    registry.registerWidget(MyNode.ID, MyWidget::new);
    registry.registerDataNode(MyNode.ID, MyNode::new);
}
  • registerDataNode takes (IClientUtils, RegistryFriendlyByteBuf) -> IDataNode — your client constructor (FriendlyByteBuf on 1.20.1).
  • registerWidget takes (IWidgetUtils utils, IDataNode entry, RelativeRect rect, int maxWidth) -> IWidget.

A widget implements com.yanny.aci.api.IWidget (getRect, getDirection, render, getTooltipLines); for a widget that lays out children, extend com.yanny.ali.api.ListWidget, which draws the connecting branch lines for you. Geometry uses RelativeRect, which is positioned relative to its parent.

An item slot is not drawn by the widget: it asks the recipe viewer for one with utils.addSlotWidget(item, node, rect), and the viewer draws the item, its count and the node's tooltip. utils.addSlotWidget(options, rect) takes a list of item nodes instead: the slot cycles through them and shows the count and tooltip of the one currently displayed — how a trade slot with several possible items is shown.

The same widget is used in EMI, JEI and REI — each viewer integration adapts IWidget to its own API, so you do not write anything viewer-specific.

Reusing built-in nodes

Before writing your own, check whether one of ALI's existing nodes fits — ItemNode (an item or tag with a count and chance), EmptyNode, AlternativesNode, GroupNode, SequenceNode, ReferenceNode, ModifiedNode. Their widgets are already registered, so you register nothing on the client.

Clone this wiki locally