RecipeLogic Lifecycle
MBD2 21.1.1RecipeLogic is the server-side scheduler of a machine, running one recipe at a time; a machine has one, or several. It decides when to search, validates and modifies candidates, simulates all handlers, acquires fuel, commits recipe IO, advances progress, waits or damps, completes outputs, and decides whether the same recipe can immediately run again.
This page follows the actual call order of the versions after 21.1.2, and says where 21.1.2 and earlier differed. Event names such as onBeforeRecipeWorking are easy to misread; use the injection table rather than inferring timing from the name.
State and persisted fields
statusCurrent scheduler state:
IDLE,WORKING,WAITING, orSUSPEND.lastRecipeEffective recipe after machine modifiers. It may be a generated copy that does not exist in
RecipeManager.lastOriginRecipeStable ID of the original recipe before modification. MBD2 uses it to rebuild the effective recipe for another cycle.
progressanddurationCompleted working ticks and the effective recipe duration.
consumeInputsAfterWorkingWhether non-per-tick inputs are deferred until the recipe completes.
fuelTimeandfuelMaxTimeRemaining fuel ticks and the duration of the fuel recipe that supplied them.
lastFuelRecipeEffective fuel recipe most recently consumed.
recipeDirtyForces a new recipe search instead of immediately reusing
lastRecipe.lastFailedMatchesSearch matches that failed modification, conditions, or final validation and are cached for retry.
These execution fields are persisted. Status, waiting reason, recipe progress, and fuel progress are also used by UI/Jade synchronization.
One server tick
MBDMachine#serverTick first publishes the cancelable MachineTickEvent. If it is not canceled, internalServerTick runs serverTick of every recipe logic allowed to run — the primary, its threads, then the additional logics — then every attached Trait's serverTick. The chart follows one logic.
Fuel countdown is outside the SUSPEND check. Existing fuel therefore burns down while the machine is WAITING, SUSPEND, or even no longer processing a recipe.
Search pipeline
1. Reuse before global search
findAndHandleRecipe first tries lastRecipe when recipeDirty == false. Reuse requires all three checks to succeed:
lastRecipe.matchRecipe(machine)— simulate non-per-tick inputs and output capacity.lastRecipe.matchTickRecipe(machine)— simulate per-tick inputs and outputs.lastRecipe.checkConditions(logic)— evaluate grouped/reversed conditions.
If reuse fails or the recipe is dirty, the cached effective recipe and origin ID are cleared before a new search.
2. When an idle logic searches
An idle logic with no cached recipe polls on the machine's staggered five-tick timer. Up to 21.1.2 every poll searched. Later versions search only with a reason:
- a handler the logic may use changed since its last search — item slots, fluid tanks, FE storages and Mekanism chemical tanks report every change;
- one of its handlers cannot report changes — any other trait, or a custom handler not overriding
notifiesAllChanges()— so every poll searches, as before; - a runtime value of the machine or one of its traits changed — a slot limit or a filter can decide whether a recipe fits;
- recipes or tags were reloaded;
- 20 polls passed without a search, as a safety net.
A poll without a reason only re-checks lastFailedMatches, the recipes that matched but could not start: a condition such as weather or redstone can come right without any handler changing.
3. Recipe-type search
A search goes through the recipe type's index, built once per recipe reload:
- No candidates if the logic has no recipe handlers.
- The index skips, without simulating them, the recipes needing an item or a fluid that none of the logic's input handlers holds. Custom ingredients, other capabilities, and handlers that cannot list what they hold never rule a recipe out, so the index only saves work: it never skips a recipe that could run.
- The rest are tried by ascending integer
priority— lower numbers first — simulating normal IO throughmatchRecipeand per-tick IO throughmatchTickRecipe. - The first candidate that passes validation starts and ends the search. Those that matched but could not start are kept in
lastFailedMatches.
Up to 21.1.2, MBDRecipeType#searchRecipe simulated every recipe of the type and sorted the matches by priority for validation to try in turn: the same choice, at the cost of simulating everything.
Conditions and machine modifiers are not applied by the search; they belong to candidate validation.
Async searching
Up to 21.1.2, enabling ConfigHolder.ASYNC_RECIPE_SEARCHING ran the initial searchRecipe simulation on Util.backgroundExecutor. Later versions search on the server thread through the index, and the option only applies to a recipe logic whose class overrides searchRecipe(), or whose recipe type overrides MBDRecipeType#searchRecipe — an addon written against the old search keeps working as it did, and its recipe type is still handed the machine. A recipe type overriding searchFuelRecipe is likewise still asked for fuel. The option also applies to a logic that has to poll with an input the index cannot read, such as an ME interface: the index rules nothing out for it, so it matches in the background as before. Custom handler simulation must stay read-only and safe for that access pattern. When the future completes, the server thread rechecks candidates and performs all modification/setup commits.
4. Candidate validation and modification
For every search match, checkMatchedRecipeAvailable performs:
origin recipe
-> onBeforeRecipeModify (mutable, cancelable)
-> configured getModifiedRecipe / parallel calculation
-> onAfterRecipeModify (mutable)
-> conditions
-> normal IO simulation
-> per-tick IO simulation
-> setupRecipeReturning/carrying null rejects that candidate. A candidate that matched the initial search but fails modification, conditions, or later revalidation is stored in lastFailedMatches, which an idle poll without a reason to search retries.
lastOriginRecipe is assigned only after setup produced a WORKING recipe. It preserves the original RecipeManager ID even when lastRecipe is a modified copy.
Handler simulation and routing
For each capability, MBD2 separates unlabelled contents from slotName groups. It tries handlers registered for the exact requested IO, then IO.BOTH handlers. Distinct handlers are tested first and must be able to satisfy the entire applicable group alone.
During matching, every handler receives simulate = true. A null return means all remaining content was handled; a non-null list is passed to later handlers. Commit repeats the route with simulate = false.
Output matching is capacity reservation by simulation, not an early output insertion. Custom handlers must return the same remainder for the same state in simulation and commit, or setup can consume only part of a recipe.
Setup and normal input consumption
setupRecipe(effectiveRecipe) has this exact order:
handleFuelRecipe()acquires fuel if required.machine.beforeWorking(recipe)publishesonBeforeRecipeWorking; cancellation aborts setup.- Read the machine's
consumeInputsAfterWorkingsetting. - If inputs are not deferred, commit non-per-tick
IO.IN. - Store
lastRecipe, setstatus = WORKING— which callspreWorkingon the input and output handlers — resetprogress = 0, and copyduration, at least 1 tick: a modifier rounding a short duration down to 0 left the recipe working forever up to 21.1.2.
Up to 21.1.2 the handlers got preWorking as step 3, before the input commit, and also when that commit then failed.
Fuel precedes the before-working event
Fuel can already be consumed before onBeforeRecipeWorking is canceled. Do not use that event as the normal eligibility check. Put eligibility in RecipeCondition or reject the candidate during recipe modification.
With consumeInputsAfterWorking = true, setup does not consume normal inputs. Every working tick re-runs matchRecipe; if the required normal input or output capacity disappears, interruptRecipe() discards progress and returns to IDLE. The inputs are finally committed immediately before normal outputs.
A working or waiting tick
handleRecipeWorking runs for both WORKING and WAITING recipes:
- If normal inputs are deferred, re-simulate all normal IO; failure interrupts the recipe.
- Recheck every
RecipeCondition. - Ensure fuel exists, acquiring a new fuel recipe when
fuelTime == 0. - Simulate per-tick IO.
- On success, commit per-tick
IO.IN, then per-tickIO.OUT. - Set status to
WORKING. - Publish
onRecipeWorkingwith the progress value before increment. - If not canceled, increment
progressandtotalContinuousRunningTime. - On a failed condition/fuel/tick check, set
WAITING, store a reason, and publishonRecipeWaiting. - If waiting damping is enabled, reduce progress by
recipeDampingValuewithout going below zero. - Handlers get
postWorkingwhen the status leavesWORKING, andpreWorkingwhen it entersWORKINGagain.
onRecipeWorking is after tick IO
Canceling onRecipeWorking interrupts the recipe, but that tick's per-tick inputs and outputs have already committed. Use a condition or handler simulation to prevent a tick from running; use this event for observation or a post-IO side effect.
When a waiting recipe becomes available, it is retried every server tick. Its first successful resumed tick commits tick IO, returns to WORKING — handlers get preWorking here, before onRecipeWorking — and increments progress.
Every preWorking is paired with exactly one postWorking, sent to the very handlers that got the preWorking even if the logic no longer uses them: waiting, suspension, interruption, completion and an unloading machine all end it, and a logic loaded mid-recipe starts its handlers again on its first tick. Up to 21.1.2 they were not paired: suspending never called postWorking, interrupting a waiting recipe called it twice, and a reload never called preWorking.
Fuel engine
Fuel is enabled on the main MBDRecipeType by its FuelRecipeConfig. The configured list contains other MBD recipe-type IDs used as fuel sources.
When fuel is required and fuelTime == 0, handleFuelRecipe:
- Searches all recipes from the configured fuel recipe types.
- Simulates their normal and per-tick IO and sorts by ascending priority.
- Publishes
onFuelRecipeModify; cancellation ornullskips that fuel candidate. - Checks the modified fuel recipe's conditions.
- Commits its non-per-tick inputs.
- Sets
fuelMaxTime = fuelRecipe.duration,fuelTime = fuelMaxTime, and storeslastFuelRecipe.
The current engine does not commit fuel-recipe outputs, and although tick contents participate in fuel matching, handleFuelRecipe only commits normal inputs. Author fuel recipes as duration plus normal inputs; do not rely on fuel outputs or per-tick fuel content.
At the end of every valid server tick, positive fuelTime decreases by one. When it becomes zero, MBDMachine#onFuelBurningFinish(lastFuelRecipe) posts a MachineFuelBurningFinishEvent, which reaches Java NeoForge.EVENT_BUS listeners, the KubeJS onFuelBurningFinish handler and the blueprint Fuel Burning Finish entry node. Up to 21.1.2 the call omitted postCustomEvent(), so only the Java listeners received it. A new fuel recipe is not searched until a main recipe setup or working tick calls handleFuelRecipe again.
Completion and the next execution
When progress >= duration, completion happens in the same server tick — once the outputs fit. Room for them was checked when the recipe started but not kept for it, so completion simulates the normal outputs first; if they no longer fit, the logic waits at full progress, with the reason, and tries again every tick. Up to 21.1.2 whatever did not fit was voided. With inputs deferred, it first checks they are all still there and interrupts the recipe if they are not. If the outputs do not fit, it takes those inputs before it waits: they are due, taking them may be what makes the room, and they cannot be taken back meanwhile. onConsumeInputsAfterWorking then fires before onAfterRecipeWorking, which waits for the room. If they cannot all be taken after all, the recipe goes IDLE without its outputs. Once the outputs fit:
machine.afterWorking()publishesonAfterRecipeWorking.- If inputs are still deferred, commit normal
IO.IN, clear the flag, then publishonConsumeInputsAfterWorking. - Call handler
postWorkingfor input and output handlers. - Commit non-per-tick
IO.OUT. - Publish
onRecipeFinish. - Decide whether the next run may reuse the recipe.
Step 1 happens before the outputs exist and step 5 after, which is the whole difference between them: a bonus product added in onAfterRecipeWorking lands in a slot the recipe's own output is about to want.
Next-run selection is controlled by two machine settings:
| Setting | Effect after completion |
|---|---|
alwaysSearchRecipe | Marks the cached recipe dirty, forcing a fresh recipe-type search |
alwaysModifyRecipe | Reloads lastOriginRecipe from RecipeManager and reapplies machine modifiers |
If the recipe is not dirty, MBD2 rechecks normal IO, tick IO, and conditions. A successful recipe calls setupRecipe(lastRecipe) immediately; its first progress tick occurs on the next server tick. Otherwise status becomes IDLE, progress/duration reset, and the next server tick either retries the cached recipe or begins a fresh search. If the immediate setup fails after all — its inputs could not be taken — the logic goes IDLE the same way; up to 21.1.2 it stayed at full progress and finished again every tick, producing its outputs for nothing.
Use alwaysSearchRecipe when another recipe with better priority may become available after each cycle. Use alwaysModifyRecipe when overclock, parallel, tier, or machine data can change the effective recipe between cycles. Leaving both disabled is fastest when the same effective recipe should repeat.
Machine event injection points
| Hook/event | Exact point | Cancellation/mutation | Recommended use |
|---|---|---|---|
onTick | Before recipe logic and all Trait server ticks | Cancel skips both | Coarse machine pause/control; keep cheap |
onBeforeRecipeModify | Before configured modifiers/parallel for each candidate | Mutable recipe; cancel rejects | Rare dynamic candidate rejection or preprocessing |
onAfterRecipeModify | After modifiers/parallel, before conditions and final simulation | Mutable recipe | Observe/finalize an effective recipe; prefer configured modifiers for normal overclock |
onFuelRecipeModify | After fuel match, before condition and fuel input commit | Mutable/cancelable | Adjust fuel duration/input or reject a fuel candidate |
onBeforeRecipeWorking | After fuel acquisition, before handler preWorking and normal input commit | Cancel aborts setup | Setup notification; not eligibility or fuel-safe cancellation |
onRecipeStatusChanged | Whenever a logic's setStatus changes its state | Read oldStatus/newStatus and recipeLogic | Animation, sound, telemetry; do not infer from the logic's not-yet-assigned field |
onRecipeWorking | After successful condition/fuel checks and per-tick IO commit, before progress increment | Cancel interrupts | Observe completed tick IO or trigger a post-IO effect |
onRecipeWaiting | After status/reason becomes waiting | Observation | UI, diagnostics, throttled feedback |
onAfterRecipeWorking | Completion before deferred inputs/outputs, and also interruption | Observation | Cleanup paired with setup; distinguish finish from interrupt if outputs matter |
onConsumeInputsAfterWorking | Deferred inputs were just committed at completion | Observation | Only fires when the machine defers inputs |
onRecipeFinish | After the outputs have been produced | Observation | "A craft completed" — bonus products, counters, sounds |
onFuelBurningFinish | Fuel counter reaches zero at end of server tick | Observation | Fuel bookkeeping; up to 21.1.2 Java NeoForge.EVENT_BUS listeners only |
Every row above reaches both KubeJS and blueprints, carrying the recipeLogic it is about — onFuelBurningFinish only after 21.1.2. On 21.1.2 and earlier, poll machine.recipeLogic.fuelTime or react to the WAITING status for a reliable "fuel ran out" signal.
Several recipe logics
A machine with several recipe logics runs everything on this page once per logic: each has its own status, recipe, progress, fuel, search and failed matches, and sees only the handlers it may use. internalServerTick runs them one after another in a fixed order, and a logic simulates and commits a recipe's IO within its own turn, so two logics never both claim an input and nothing needs locking. The machine's status sums up its logics, while every event names the logic it is about.
Where behavior belongs
| Requirement | Preferred extension point |
|---|---|
| World/machine eligibility | RecipeCondition |
| Consume or produce a resource | IRecipeHandlerTrait simulate/commit |
| Overclock, parallel, duration/content transformation | Recipe modifiers / getModifiedRecipe |
| Reserve/release external transaction state | Handler preWorking / postWorking |
| Visual state and telemetry | Status and waiting events |
| One-off post-tick behavior | onRecipeWorking, remembering tick IO already committed |
| Something that happens once a craft is done | onRecipeFinish |
| Per-machine configuration | Runtime values |
Do not move resource accounting into generic events. Conditions can be evaluated repeatedly, searches can be asynchronous, and working events may happen after IO; the handler contract is the only layer designed for simulation followed by authoritative commit.