Recipes, instant crafting and timed crafting queues that set aside their ingredients in an inventory.
Import blitz.crafting As Craft gives your program these, with Craft. in front. Each name leads to its part of the reference.
LastError:Str()ParseRecipes:RecipeBook(document:Int, items:Inv.ItemCatalog)LoadRecipes:RecipeBook(file:Str, items:Inv.ItemCatalog)CreateCrafting:Crafter(book:RecipeBook, inventory:Inv.Inventory, rules:UnlockRules)PreviewCraft:CraftPreview(controller:Crafter, recipe:Str, quantity:Int = 1)CraftNow:Int(controller:Crafter, recipe:Str, quantity:Int = 1)QueueCraft:Int(controller:Crafter, recipe:Str, quantity:Int = 1)CancelCraft:Int(controller:Crafter, job:Int)UpdateCrafting(controller:Crafter, seconds:Double)NextEvent:CraftEvent(controller:Crafter)CaptureCrafting:Int(controller:Crafter)RestoreCrafting:Crafter(book:RecipeBook, inventory:Inv.Inventory, snapshot:Int, rules:UnlockRules)JobInfoid:Int, recipe:Str, quantity:Int, remainingSeconds:Double, totalSeconds:Double, status:IntRecipeBookID:Str(), Version:Int(), Count:Int(), RecipeID:Str(index:Int), UnlockName:Str(recipe:Str)CraftPreviewAvailable:Int(), Status:Int(), Reason:Str(), Revision:Long(), SearchWork:Int(), IngredientCount:Int(), ReadIngredient(index:Int, output:Inv.ItemDelta), MissingCount:Int(), ReadMissing(index:Int, output:Inv.ItemDelta), OutputCount:Int(), ReadOutput(index:Int, output:Inv.ItemDelta)CraftEventKind:Int(), JobID:Int(), RecipeID:Str(), Quantity:Int()CrafterJobCount:Int(), ReadJob(index:Int, output:JobInfo)UnlockRulesUnlocked:Int(recipe:Str)CraftStatusReady, MissingIngredients, Locked, NoCapacity, TooComplex, Invalid, ChangedJobStatusQueued, Running, WaitingForSpaceEventKindQueued, Completed, Cancelled, WaitingForSpaceCrafting turns what's in a bag into something new: two herbs into a tonic, ore and coal into an iron ingot. You list your recipes in a file, and the module checks the bag has the ingredients and room for the result, then swaps one for the other, all at once or not at all. Some things can be made straight away. Others take time: their ingredients are set aside, so nothing else can use them, and the job waits its turn in a queue. It builds on the Inventory module, which keeps the bag.

Open workshop.bb and run it. Press B to brew a tonic, which happens at once, and S a couple of times to smelt iron ingots. Watch the ore and coal being set aside, and the jobs taking turns.
; A workshop. B brews a tonic at once, S smelts an iron ingot, which takes
; three seconds, and Escape quits.
Dialect "modern"
Import blitz.inventory As Inv
Import blitz.crafting As Craft
Graphics 720, 270, 0, 2
SetBuffer BackBuffer()
ClsColor 18, 52, 79
; The items and recipes come with the module, in the folder above this program.
Local items:Inv.ItemCatalog = Inv.LoadItems("../items.json")
Local book:Craft.RecipeBook = Craft.LoadRecipes("../recipes.json", items)
If book = Null Then RuntimeError Craft.LastError()
Local bag:Inv.Inventory = Inv.CreateInventory(items, 6)
Inv.AddItems bag, "herb", 6
Inv.AddItems bag, "ore", 6
Inv.AddItems bag, "coal", 3
; The bench makes things from the recipes, out of what's in the bag.
Local bench:Craft.Crafter = Craft.CreateCrafting(book, bag, Null)
Local job:Craft.JobInfo = New Craft.JobInfo
Local message:Str = "B: brew a tonic S: smelt an ingot"
Local previous:Int = MilliSecs()
While Not KeyDown(1)
If KeyHit(48) ; B: a tonic is made from two herbs at once, or not at all.
If Not Craft.CraftNow(bench, "tonic") Then message = "Not enough herbs"
EndIf
If KeyHit(31) ; S: two ore and a coal are set aside, and the job is queued.
If Craft.QueueCraft(bench, "smelt") = 0 Then message = "Not enough ore and coal"
EndIf
; Tell the bench how much time has passed, then read its news.
Local now:Int = MilliSecs()
Craft.UpdateCrafting bench, Double(now - previous) / 1000D
previous = now
Local news:Craft.CraftEvent = Craft.NextEvent(bench)
While news <> Null
If news.Kind() = Craft.EventKind.Completed Then message = "Finished: " + news.RecipeID()
Delete news ; News is given to you, so delete it.
news = Craft.NextEvent(bench)
Wend
Cls
Local slot:Int
For slot = 0 To Inv.InventorySlots(bag) - 1
Local view:Inv.StackView = Inv.InventorySlot(bag, slot)
Local x:Int = 20 + slot * 115
Color 40, 90, 140
Rect x, 40, 105, 85, True
Color 255, 255, 255
If view.Item() <> ""
Text x + 52, 55, items.Label(view.Item()), True
Text x + 52, 75, "x" + view.Quantity(), True
; Ingredients set aside for a job stay in the bag until it's done.
Color 255, 200, 90
If view.Reserved() > 0 Then Text x + 52, 98, view.Reserved() + " set aside", True
EndIf
Next
; Jobs take turns: only the first in the queue is being made.
Local index:Int
For index = 0 To bench.JobCount() - 1
bench.ReadJob(index, job)
Local y:Int = 150 + index * 28
Color 40, 90, 140
Rect 20, y, 300, 18, True
Color 90, 200, 120
Rect 20, y, Int(300D * (1D - job.remainingSeconds / job.totalSeconds)), 18, True
Color 255, 255, 255
Text 335, y + 2, "Job " + job.id + ": " + job.recipe
Next
Text 20, 240, message
Flip
Wend
Delete job
Delete bench
Delete bag
Delete book
Delete items
End
CraftNow makes a tonic on the spot. QueueCraft sets the ingredients for an
ingot aside and adds a job to the queue. The ore and coal stay in the bag,
marked as set aside, until the job is done, and then they're swapped for the
ingot. The bench never looks at the clock: each frame, UpdateCrafting tells
it how many seconds have passed, and NextEvent hands you its news, such as a
job that has finished.
The program loads its recipes from recipes.json, which comes with the module.
Your game has a file of its own:
{
"id": "my-recipes",
"version": 1,
"searchLimit": 4096,
"recipes": [
{"id":"tonic", "seconds":2,
"ingredients":[{"quantity":2, "items":["herb"]}],
"outputs":[{"item":"tonic", "quantity":1}]},
{"id":"smelt", "seconds":3,
"ingredients":[{"quantity":2, "items":["ore"]}, {"quantity":1, "items":["coal", "charcoal"]}],
"outputs":[{"item":"ingot", "quantity":1}]}
]
}
id is the name your program uses for the recipe.seconds is how long a queued job takes. CraftNow doesn't wait.ingredients is a list of groups. Each group takes quantity of one of its
items, so smelting takes two ore and either a coal or a charcoal.outputs lists what the recipe makes."unlock":"alchemy" to a recipe to give it a name that your unlock
rules can check (see below).Every item must be in your item catalog.
searchLimit limits how hard the bench may look for a mix of ingredients that
works, so a recipe with lots of choices can never hold up a frame. 4096 is
plenty for recipes like these. Give the file's own id and version too: a
saved queue only loads with a recipe book that has the same ones.
Ask for a preview. It changes nothing, and says what's missing. A preview is yours, so delete it when you've finished with it.
Local plan:Craft.CraftPreview = Craft.PreviewCraft(bench, "smelt")
If plan.Available()
Text 10, 10, "Ready to smelt"
Else
Local need:Inv.ItemDelta = New Inv.ItemDelta
Local line:Int
For line = 0 To plan.MissingCount() - 1
plan.ReadMissing(line, need)
Text 10, 10 + line * 20, "Needs " + need.quantity + " more " + items.Label(need.item)
Next
Delete need
EndIf
Delete plan
Where a group has a choice, the preview suggests just one of its items. If
nothing is missing, plan.Status() says what else is wrong, such as
Craft.CraftStatus.NoCapacity when there's no room for what it makes.
Give the number you want. Three tonics take six herbs, and you get all three or none. Queued, three ingots take three times as long.
Craft.CraftNow bench, "tonic", 3 Craft.QueueCraft bench, "smelt", 3
QueueCraft gives you the job's number. Cancel it with that, and its
ingredients are free to use again. It works even when the bag is full.
Local smelting:Int = Craft.QueueCraft(bench, "smelt") ; Later, if the player changes their mind: If Craft.CancelCraft(bench, smelting) Then message = "Smelting cancelled"
Write a Type that Implements Craft.UnlockRules. The bench asks its
Unlocked method about a recipe each time you preview, craft or queue it.
Type Skills Implements Craft.UnlockRules
Field book:Craft.RecipeBook
Field alchemy:Int
Method Unlocked:Int(recipe:Str)
If Self.book.UnlockName(recipe) = "alchemy" Then Return Self.alchemy
Return True
End Method
End Type
Give one to the bench in place of Null, and delete it after the bench:
Local skills:Skills = New Skills skills.book = book Local bench:Craft.Crafter = Craft.CreateCrafting(book, bag, skills)
A locked recipe can't be made or queued until skills.alchemy is True.
Jobs already in the queue carry on.
Save the bag and the bench together, in one file, so that a job and the ingredients set aside for it always match.
Local saved:Int = CreateJsonObject() Local part:Int = Inv.CaptureInventory(bag) JsonObjectSet saved, "bag", part FreeJson part part = Craft.CaptureCrafting(bench) JsonObjectSet saved, "bench", part FreeJson part If Not SaveJsonAtomic(saved, "workshop.json") Then Print "Saving failed" FreeJson saved
To load, make a new bag and then a new bench from the saved parts. Only when both have worked, swap them in for the old ones.
Local loaded:Int = LoadJson("workshop.json")
If loaded
Local bagPart:Int = JsonObjectGet(loaded, "bag")
Local benchPart:Int = JsonObjectGet(loaded, "bench")
Local newBag:Inv.Inventory = Inv.RestoreInventory(items, bagPart)
Local newBench:Craft.Crafter = Null
If newBag <> Null Then newBench = Craft.RestoreCrafting(book, newBag, benchPart, Null)
Local good:Int = newBench <> Null
If good Then good = Inv.UnclaimedReservations(newBag) = 0
If good
Delete bench ; The old bench goes before its bag.
Delete bag
bench = newBench
bag = newBag
Else
Delete newBench
Delete newBag
EndIf
FreeJson bagPart
FreeJson benchPart
FreeJson loaded
EndIf
The last check makes sure every ingredient set aside in the saved bag belongs to a saved job. Jobs carry on where they were: time spent away from the game doesn't count.
Blip's tip
Read the bench's news every frame, even if you only use
CraftNow: each tonic it makes leaves a note too. Once 256 notes are waiting, or fewer while jobs are queued, the bench refuses new work until you read them.
UpdateCrafting.Three more complete programs come with the module:
The reference below has every function and rule.
Deterministic recipes, atomic instant crafting and a bounded timed queue backed
by inventory reservations. Requires the Extended object-lifecycle/private-member
feature and blitz.inventory; the core has no UI, saves, quests or clock import.
Blitz3D+ comes with both packages. Import blitz.crafting As Craft and
blitz.inventory As Inv.
The examples load the item catalog and recipe book that come with the module,
items.json and recipes.json in its folder. Importing the module performs no
I/O, starts no timer and creates no global game objects.
; Your game's own item catalog and recipe book.
Local items:Inv.ItemCatalog = Inv.LoadItems("items.json")
Local book:Craft.RecipeBook = Craft.LoadRecipes("recipes.json", items)
If book = Null Then RuntimeError Craft.LastError()
Local bag:Inv.Inventory = Inv.CreateInventory(items, 4)
Inv.AddItems bag, "herb", 2
Local bench:Craft.Crafter = Craft.CreateCrafting(book, bag, Null)
If Craft.CraftNow(bench, "tonic") = False Then Print Craft.LastError()
Delete bench
Delete bag
Delete book
Delete items
The examples include the complete ownership and event cleanup paths:
examples/quickstart.bb: overlapping alternatives and one atomic craft.examples/bench.bb: Tab selects a recipe; Up/Down selects quantity; U toggles
alchemy; R queues; C crafts immediately; X cancels the oldest job; F fills the
three slots; O removes ore. The screen shows selected ingredients, missing
suggestions, physical/reserved/available items and timed progress. Start with
U, R, F; wait for output space, then X to cancel in the full bag. Repeat R and
wait, then O to allow completion. Alternative groups are visible in overlap.examples/save_load.bb: initially paused; R queues, T advances half a second,
S saves, X cancels, L restores the saved progress. P resumes time. The owned
crafting-example-save.json file holds both snapshots. This illustrates local
file save/load; browser reload durability belongs to the saves integration.All examples accept --verify for a finite assertion-bearing run. Ordinary
controls execute the same game operations; Escape deletes unfinished work.
{"id":"my-recipes","version":1,"searchLimit":4096,"recipes":[
{"id":"tonic","seconds":2,"unlock":"alchemy",
"ingredients":[{"quantity":2,"items":["flower","herb"]}],
"outputs":[{"item":"tonic","quantity":1}]}
]}
Book and recipe IDs use 1–128 ASCII letters, digits, _, - or .. Version is
1–2147483647; searchLimit is required and 1–1000000; a book has at most 256
recipes (an empty book is valid). Recipes require 1–16 ingredient groups, each
with 1–16 distinct known item IDs, and 1–64 outputs. Quantities are positive Ints.
Repeated output IDs are aggregated with checked bounds. Seconds must be finite
and non-negative. All item IDs must exist in the borrowed item catalog. Optional
unlock names follow the ID rules; absent/empty means no metadata. Extra JSON
metadata is accepted and ignored.
An entire group's batch quantity uses one item kind: two requested crafts of a
two-item group require four of one alternative, not two of each. Alternatives
are sorted by item ID; group order stays as authored. The planner searches across
all groups, undoing provisional choices when they conflict. It also tries later
allocations when the first ingredient-feasible allocation cannot fit its outputs.
Capacity includes weight, reserved/equipped items, stack limits and slots freed
by consumption, through the inventory's public CanApplyChanges planner.
Each alternative attempt and complete capacity check consumes one search unit.
Backtracking itself consumes no unit. An exhausted budget with unresolved
possibilities yields TooComplex, even if one non-fitting allocation was found;
it never claims that unresolved ingredients are missing. SearchWork() reports
actual units. Use a reasonable per-book limit for frame-time budgeting.
LoadRecipes(file, items) and ParseRecipes(json, items) return an owned
RecipeBook, or Null plus LastError() for invalid data. Parse borrows its JSON
and copies definitions; the caller can free the JSON immediately. RecipeBook
has ID(), Version(), Count(), RecipeID(index) and UnlockName(recipe).
Unknown unlock metadata returns an empty string.
CreateCrafting(book, inventory, rules) returns an owned Crafter; pass Null
explicitly for no rules (the compiler cannot express a typed Null default).
An inventory using the book's own catalog object is accepted at once. For another
catalog object, the inventory's contents must restore under the book's catalog
(same ID/version, every held item still valid), checked once at construction
with public inventory capture/restore. The catalog, book, inventory and optional
rules stay borrowed. Incompatible catalogs return Null plus LastError.
PreviewCraft(controller, recipe, quantity=1) returns an owned CraftPreview.
Delete it after use. It never reserves or mutates inventory. Methods:
| Query | Meaning |
|---|---|
Available() | True only for Ready |
Status(), Reason() | Result enum and readable blocked reason |
Revision() | Inventory revision captured before unlock callback |
SearchWork() | Charged allocation search units |
IngredientCount(), ReadIngredient(index, delta) | Chosen aggregate inputs |
MissingCount(), ReadMissing(index, delta) | Suggested missing quantities |
OutputCount(), ReadOutput(index, delta) | Aggregate batch outputs |
Read methods copy into a caller-owned Inv.ItemDelta, in lexical item-ID order;
mutating it cannot change the preview. CraftStatus is Ready=0,
MissingIngredients=1, Locked=2, NoCapacity=3, TooComplex=4, Invalid=5, Changed=6.
Missing quantities are a deterministic shopping suggestion from every group's
first alternative, not a minimum shopping list. A missing/invalid result can
contain partial output/allocation diagnostics; use its status before acting.
CraftNow(controller, recipe, quantity=1) returns True after one successful
atomic ApplyChanges at the freshly planned revision. It always replans and
rechecks unlock rules. Outputs never fund the same operation's gross inputs,
including recipes consuming and producing the same item ID. A failed operation
does not mutate inventory or emit a completion event.
UnlockRules.Unlocked(recipeID) is called for preview and newly requested work.
It receives the recipe ID; a game can consult book.UnlockName(recipeID) for
metadata. No rules means every recipe is unlocked. Read-only controller queries
are allowed inside the callback. Driving or deleting that active controller is
a programming error. If the callback changes the inventory revision, the result
is Changed and no crafting mutation occurs. Accepted jobs do not recheck unlocks
on completion, restore or destruction.
Unknown recipes, insufficient ingredients, capacity, complexity, changed rules, full queues/events and malformed snapshots are recoverable. Null/deleted required objects, a deleted borrowed owner, non-positive requested quantity, invalid read indices/outputs, reentrant callbacks and non-finite/negative time are programming errors. LastError is module-global transient diagnostic text; retain a preview's Reason when displaying a stable result. A successful operation may replace prior diagnostics. Constructor/planner scratch objects are private.
QueueCraft(controller, recipe, quantity=1) returns a stable positive Int job ID
or zero. It reserves ingredients at the planned revision after preparing job and
notification storage. Physical ingredients stay in inventory, unavailable to
other work. Current output capacity is optional for queueing; Ready and
NoCapacity plans may queue. At most 32 jobs exist per controller, with monotonically
increasing IDs that fail cleanly at Int exhaustion.
UpdateCrafting(controller, seconds) advances only the head job. A spanning
delta can finish several jobs, at most the queue size. Zero-duration work can
complete on UpdateCrafting(0). If output commit cannot fit, the head becomes
WaitingForSpace and retains its exact reservation; subsequent updates retry.
Unused elapsed time is discarded while waiting. Following jobs do not accumulate
time behind it. No automatic or wall-clock update occurs.
CancelCraft(controller, jobID) removes a known unfinished job and releases its
claim in place. It succeeds in a full bag; unknown/completed IDs return False.
controller.JobCount() and ReadJob(index, callerOwnedJobInfo) expose copied
ID, recipe, quantity, remainingSeconds, totalSeconds and status. JobStatus is
Queued=0, Running=1, WaitingForSpace=2; only the head may be active.
NextEvent(controller) transfers one owned CraftEvent, or Null when empty.
Drain regularly and delete transferred events. Kind(), JobID(), RecipeID()
and Quantity() are immutable scalar queries and remain usable after controller
deletion. EventKind is Queued=0, Completed=1, Cancelled=2, WaitingForSpace=3.
Job ID zero means instant crafting. Completion/cancellation happen once per job;
repeated waiting retries do not emit repeated WaitingForSpace notifications.
The event limit is 256 including reserved headroom: each pending job reserves a terminal notification and, until waiting, a waiting transition. Queue/instant requests fail before mutation if the backlog leaves insufficient headroom. An undrained backlog cannot block cancellation or completion of accepted work.
CaptureCrafting(controller) returns owned JSON. Capture inventory and crafting
together without game mutations between captures. Persist both in one record.
Format blitz-crafting-v2 stores the book ID and version, the paired inventory
revision, and each job's ID, recipe ID, selected inputs (in the recipe's group
order), outputs, reservation ID, total and remaining duration, plus retained
events. The inventory alone stores physical reserved allocations. Completed jobs
are absent; retained completion events are notifications, never instructions to
grant another output. No offline time is inferred.
Restore into independent candidates, in this order:
candidateBag = Inv.RestoreInventory(catalog, inventoryJSON).candidate = Craft.RestoreCrafting(book, candidateBag, queueJSON, rules).Inv.UnclaimedReservations(candidateBag)=0.Restore requires exactly blitz-crafting-v2 (0.1.0 preview v1 saves are
rejected), the same book ID and version, and a candidate inventory at the saved
revision. It does not compare whole books. Each saved job is rebuilt from the
current recipe it names: its choices must still be legal and its ingredients,
outputs and total duration must match. Each retained event's recipe must still
exist. A removed or changed referenced recipe is rejected with a diagnostic
naming it. Other recipes, searchLimit and unlock metadata may change freely,
so an empty queue with drained events restores across any book edit. Bump the
book version when old saves need an explicit game migration, not silent
best-effort loading.
Restore also validates bounds, chronological unique job IDs, distinct reservation IDs, event headroom, and reservation Count/ReadItem totals. It calls public TakeReservation once per job. Claims already taken are released if a later claim fails; the failed candidate inventory must therefore be discarded. The installed game remains untouched. The final unclaimed check belongs to the game because several controllers may legitimately share an inventory.
Delete previews and transferred events yourself. Delete crafters before borrowed inventory/book/rules; delete books and inventories before the catalog. Crafter destruction cancels all live reservations and frees jobs and undelivered events. It invokes no callbacks, grants no outputs and writes no save. Inventory retains its own reservation lifetime backstop. Caller-owned JobInfo/ItemDelta records and captured JSON are never deleted by the module.
Equipment/durability catalysts, mixed substitutions within one group, item scripts, offline progress, network authority, crafting UI frameworks and automatic save/quest integrations are deferred. A game may consume IDs/events through separate integrations without adding those dependencies to this core.