Custom Trait and Recipe Handler
MBD2 21.1.1
A custom recipe capability describes content; a trait supplies machine-local state and a handler that can consume or produce it. MBD2 creates one runtime trait for each TraitDefinition attached to a machine.
This page completes the heat_units capability from the previous page.
1. Define the editor-facing type
@Getter
@Setter
public final class HeatBufferTraitDefinition
extends RecipeCapabilityTraitDefinition {
@LDLRegister(
name = "heat_buffer",
registry = "mbd2:trait_definition_type",
group = "trait",
priority = -100
)
public static final TraitDefinitionType<HeatBufferTraitDefinition> TYPE =
new TraitDefinitionType<>("heat_buffer", "trait") {
@Override
public HeatBufferTraitDefinition createDefinition() {
return new HeatBufferTraitDefinition();
}
};
@Configurable(name = "config.examplemod.heat_buffer.capacity")
@ConfigNumber(range = {1, Integer.MAX_VALUE})
private int capacity = 10_000;
@Override
public ITrait createTrait(MBDMachine machine) {
return new HeatBufferTrait(machine, this);
}
@Override
public TraitDefinitionType<?> type() {
return TYPE;
}
@Override
public IGuiTexture getIcon() {
return new ItemStackTexture(Items.BLAZE_POWDER);
}
}RecipeCapabilityTraitDefinition already provides editor fields for:
| Field | Runtime effect |
|---|---|
name | Stable trait identifier used by scripts and recipe slotName |
priority | Ordering among definitions |
recipeHandlerIO | Whether the handler accepts recipe input, output, or both |
isDistinct | Prevents contents of this capability being combined across handlers |
slotNames | Names this handler advertises for targeted recipe content |
Override allowMultiple() or isCompatibleWith(other) only when duplicates or combinations would be invalid. Return isMandatory() == true only for a definition automatically added by a specialized machine type.
2. Implement persistent runtime state
@Getter
public final class HeatBufferTrait extends RecipeCapabilityTrait {
public static final ManagedFieldHolder MANAGED_FIELD_HOLDER =
new ManagedFieldHolder(HeatBufferTrait.class);
@Persisted
@DescSynced
private int stored;
private final HeatRecipeHandler recipeHandler = new HeatRecipeHandler();
public HeatBufferTrait(MBDMachine machine, HeatBufferTraitDefinition definition) {
super(machine, definition);
}
@Override
public ManagedFieldHolder getFieldHolder() {
return MANAGED_FIELD_HOLDER;
}
@Override
public HeatBufferTraitDefinition getDefinition() {
return (HeatBufferTraitDefinition) super.getDefinition();
}
public void setStored(int value) {
int next = Math.clamp(value, 0, getDefinition().getCapacity());
if (next == stored) return;
stored = next;
notifyListeners();
}
@Override
public List<IRecipeHandlerTrait<?>> getRecipeHandlerTraits() {
return List.of(recipeHandler);
}@Persisted writes the field to the machine block entity. @DescSynced includes it in initial/description synchronization. Use the LDLib2 sync annotations appropriate to the field's actual update frequency; persistent data and client-visible data are separate decisions.
3. Implement simulate and commit
private final class HeatRecipeHandler extends RecipeHandlerTrait<Integer> {
private HeatRecipeHandler() {
super(HeatBufferTrait.this, HeatUnitsCapability.CAP);
}
@Override
public List<Integer> handleRecipeInner(
IO io,
MBDRecipe recipe,
List<Integer> left,
@Nullable String slotName,
boolean simulate) {
if (!compatibleWith(io)) return left;
int requested = left.stream().mapToInt(Integer::intValue).sum();
int handled;
if (io == IO.IN) {
handled = Math.min(stored, requested);
if (!simulate) setStored(stored - handled);
} else {
int room = getDefinition().getCapacity() - stored;
handled = Math.min(room, requested);
if (!simulate) setStored(stored + handled);
}
int remaining = requested - handled;
return remaining == 0 ? null : List.of(remaining);
}
}
}The return value is a protocol:
nullmeans the handler completed all remaining content.- A non-empty list is passed to later handlers/proxies.
simulate == truemust calculate the same result without changing state.simulate == falseruns on the authoritative path and may commit the exact simulated operation.
MBD2 calls preWorking when recipe logic enters working and postWorking when it leaves. Override those on the handler for reservations or external transactions, not for ordinary scalar storage.
4. Add it to a machine
In the editor, add Heat Buffer, set a stable trait name such as heat, select recipe IO, and set capacity. In Java:
var heat = new HeatBufferTraitDefinition();
heat.setName("heat");
heat.setCapacity(20_000);
heat.setRecipeHandlerIO(IO.BOTH);
heat.getSlotNames().add("main_heat");
machineSettings.addTraitDefinition(heat);A recipe using .slotName("main_heat") will only reach handlers advertising that name. The definition's name is used by machine.getTraitByName("heat"); it is a different concept from slotNames.
5. Optional UI support
Implement IUIProviderTrait on the definition when the editor should generate a widget:
@Override
public TraitUILayoutType getTraitUILayoutType() {
return TraitUILayoutType.BAR;
}
@Override
public void createTraitUITemplate(UIElement container) {
container.addChild(new ProgressBar()
.setId(uiId())
.layout(layout -> layout.height(14)));
}
@Override
public void initTraitUI(ITrait trait, UI ui) {
if (!(trait instanceof HeatBufferTrait heat)) return;
ui.selectId(uiId(), ProgressBar.class).forEach(bar ->
bar.bind(DataBindingBuilder.floatValS2C(() ->
(float) heat.getStored() / heat.getDefinition().getCapacity()
).build()));
}uiId() is ui:<trait-name>. Use server-to-client bindings for authoritative values; do not trust a client widget as storage.
6. Optional NeoForge block capability
If pipes or another mod must query the trait, extend SimpleCapabilityTraitDefinition<T, C> and SimpleCapabilityTrait<T, C>. Its nested Type registers the block capability on every MBD machine block entity and on proxy parts:
public static final BlockCapability<IHeatStorage, @Nullable Direction> HEAT =
BlockCapability.createSided(
ResourceLocation.fromNamespaceAndPath("examplemod", "heat"),
IHeatStorage.class);
public static final SimpleCapabilityTraitDefinition.Type<
IHeatStorage, @Nullable Direction, HeatBufferTraitDefinition> TYPE =
new SimpleCapabilityTraitDefinition.Type<>("heat_buffer", "trait") {
@Override protected BlockCapability<IHeatStorage, @Nullable Direction> getCapability() {
return HEAT;
}
@Override protected IHeatStorage merge(List<IHeatStorage> values) {
return new HeatStorageList(values);
}
@Override public HeatBufferTraitDefinition createDefinition() {
return new HeatBufferTraitDefinition();
}
};The runtime trait implements getCapContent(IO capabilityIO) and returns a wrapper that enforces insertion/extraction direction. merge must preserve IO restrictions and simulation semantics when several traits or proxied controller capabilities are combined.
7. Make settings overridable per machine
A definition field is shared by every machine placed from it. Front it with a runtime value and one machine can override it — from a script, a blueprint node, or a UI:
public final RuntimeValue<Integer> capacity =
runtimeValues.ofInt("capacity", () -> getDefinition().getCapacity())
.onChanged(this::onCapacityChanged);The fallback must be a lambda: it is evaluated lazily, because getDefinition() is not available while the owner's field initialisers run. Read through capacity.get() at the point of use rather than caching the value. onChanged is for invalidation a plain read cannot do for itself, such as invalidateCapabilities(); it may run before the block entity is in a level, and on either side.
Slot values must be immutable — RuntimeValueStorage#serializeNBT runs on LDLib's async persistence thread while writes come from the game thread.
Runtime lifecycle
ITrait provides onMachineLoad, onChunkUnloaded, onMachineUnLoad, onMachineRemoved, drop, neighbour-change, serverTick and clientTick hooks. Register external listeners and caches on load, release them on unload and removal. Keep gameplay mutation on the server.