Crafting

Recipes, instant crafting and timed crafting queues that set aside their ingredients in an inventory.

Commands

Import blitz.crafting As Craft gives your program these, with Craft. in front. Each name leads to its part of the reference.

Functions

Types

Interfaces

Enums

Guide

Crafting 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.

A bag of herbs, iron ore, coal and a tonic, with ore and coal set aside for two smelting jobs, one part done

Try it

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.

Describe your recipes

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}]}
  ]
}

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.

How do I...

Show what a recipe still needs?

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.

Make several at once?

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

Cancel a job?

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"

Lock recipes until the player learns them?

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 workshop and load it again?

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.

Good to know

More examples

Three more complete programs come with the module:

The reference below has every function and rule.

Reference

Needs
blitz.inventory
Examples
bench.bb, quickstart.bb, save_load.bb

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:

All examples accept --verify for a finite assertion-bearing run. Ordinary controls execute the same game operations; Escape deletes unfinished work.

Recipe data

{"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.

Public API and errors

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:

QueryMeaning
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.

Timed queue and events

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.

Paired persistence and ownership

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:

  1. candidateBag = Inv.RestoreInventory(catalog, inventoryJSON).
  2. candidate = Craft.RestoreCrafting(book, candidateBag, queueJSON, rules).
  3. Restore every other queue/owner sharing that candidate inventory.
  4. Require all owners valid and Inv.UnclaimedReservations(candidateBag)=0.
  5. Only then delete the installed controller(s) and inventory, and install both candidates. On failure delete candidate controllers and candidate inventory.

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.

Scope

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.