Bags, item stacks and equipment slots, with safe transfers and reservations.
Import blitz.inventory As Inv gives your program these, with Inv. in front. Each name leads to its part of the reference.
LastError:Str()ParseItems:ItemCatalog(document:Int)LoadItems:ItemCatalog(file:Str)CreateInventory:Inventory(catalog:ItemCatalog, slots:Int = 20, weightLimit:Double = 0D)AddItems:Int(bag:Inventory, item:Str, quantity:Int = 1)RemoveItems:Int(bag:Inventory, item:Str, quantity:Int = 1)MoveItems:Int(source:Inventory, destination:Inventory, slot:Int, quantity:Int)EquipItem:Int(bag:Inventory, slot:Int, equipmentSlot:Str)UnequipItem:Int(bag:Inventory, equipmentSlot:Str)CountItem:Int(bag:Inventory, item:Str)InventorySlot:StackView(bag:Inventory, slot:Int)InventorySlots:Int(bag:Inventory)InventoryWeight:Double(bag:Inventory)EquipmentItem:Str(bag:Inventory, equipmentSlot:Str)InventoryRevision:Long(bag:Inventory)InventoryCatalog:ItemCatalog(bag:Inventory)CanApplyChanges:Int(bag:Inventory, changes:ItemDelta List, expectedRevision:Long = -1L)ApplyChanges:Int(bag:Inventory, changes:ItemDelta List, expectedRevision:Long = -1L)ReserveItems:Reservation(bag:Inventory, ingredients:ItemDelta List, expectedRevision:Long = -1L)CommitReservation:Int(heldReservation:Reservation, outputs:ItemDelta List)TakeReservation:Reservation(bag:Inventory, id:Long)UnclaimedReservations:Int(bag:Inventory)CaptureInventory:Int(bag:Inventory)RestoreInventory:Inventory(catalog:ItemCatalog, snapshot:Int)ItemDeltaitem:Str, quantity:IntItemCatalogID:Str(), Version:Int(), Label:Str(item:Str)StackViewItem:Str(), Quantity:Int(), Reserved:Int()InventoryReservationID:Long(), Count:Int(), ReadItem(index:Int, output:ItemDelta)Inventory gives your game somewhere to keep things: a backpack, a chest, a shop's shelves. A bag has a number of slots and, if you like, a weight limit. Items of the same kind stack up in a slot, and things like swords can go in equipment slots such as a hand. The module looks after the counting, so items never vanish or multiply by mistake as they move about.

Open backpack.bb and run it. Press H to pick up herbs, S to pick up a sword, E to take the sword in your hand and D to drop a herb.
; A backpack with eight slots. H picks up herbs, S picks up a sword,
; E equips the sword, D drops a herb and Escape quits.
Dialect "modern"
Import blitz.inventory As Inv
; Where an item is in the bag, or -1 if it isn't there.
Function FindItem:Int(bag:Inv.Inventory, item:Str)
Local slot:Int
For slot = 0 To Inv.InventorySlots(bag) - 1
Local view:Inv.StackView = Inv.InventorySlot(bag, slot)
If view.Item() = item Then Return slot
Next
Return -1
End Function
Graphics 640, 230, 0, 2
SetBuffer BackBuffer()
ClsColor 18, 52, 79
; The catalog describes every kind of item. The bag holds some of them.
; This one comes with the module, in the folder above this program.
Local catalog:Inv.ItemCatalog = Inv.LoadItems("../items.json")
If catalog = Null Then RuntimeError Inv.LastError()
Local bag:Inv.Inventory = Inv.CreateInventory(catalog, 8, 20D) ; 8 slots, 20 weight
Local message:Str = "H: herbs S: sword E: equip D: drop a herb"
While Not KeyDown(1)
If KeyHit(35) ; H
; AddItems says how many fitted. The rest stay on the ground.
message = "Picked up " + Inv.AddItems(bag, "herb", 4) + " of 4 herbs"
EndIf
If KeyHit(31) ; S
message = "Picked up " + Inv.AddItems(bag, "sword") + " sword"
EndIf
If KeyHit(18) ; E
Local sword:Int = FindItem(bag, "sword")
If sword < 0
message = "No sword to equip"
ElseIf Inv.EquipItem(bag, sword, "hand")
message = "Sword in hand"
EndIf
EndIf
If KeyHit(32) ; D
message = "Dropped " + Inv.RemoveItems(bag, "herb") + " herb"
EndIf
; Draw each slot with what it holds, then the hand and the weight.
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 * 76
Color 40, 90, 140
Rect x, 40, 70, 70, True
Color 255, 255, 255
If view.Item() <> ""
Text x + 35, 60, catalog.Label(view.Item()), True
Text x + 35, 80, "x" + view.Quantity(), True
EndIf
Next
; Equipment has its own named slots, outside the backpack.
Color 40, 90, 140
Rect 20, 130, 70, 70, True
Color 255, 255, 255
Text 55, 140, "Hand", True
Text 55, 165, catalog.Label(Inv.EquipmentItem(bag, "hand")), True
Text 110, 165, "Weight: " + Float(Inv.InventoryWeight(bag)) + " of 20"
Text 110, 140, message
Flip
Wend
Delete bag
Delete catalog
End
Herbs stack ten to a slot, so twelve of them fill one slot and start another. When the sword goes in your hand it leaves the backpack, but you still carry its weight.
The program loads its items from items.json, which comes with the module.
Your game has a file of its own, listing every kind of item it has:
{
"id": "my-game",
"version": 1,
"items": [
{"id":"herb", "label":"Herb", "stack":10, "weight":0.5, "equipment":[]},
{"id":"sword", "label":"Sword", "stack":1, "weight":3, "equipment":["hand"]}
]
}
id is the name your program uses, and label the name players see.stack is how many fit in one slot. Anything that can be equipped stacks
one to a slot.weight is in whatever unit suits your game.equipment lists the equipment slots the item can go in.Give the file's own id and version too. Saved bags remember them, so that
an old save isn't loaded with the wrong set of items.
AddItems returns how many fitted. Take only that many from the world, and
leave the rest lying there.
Local taken:Int = Inv.AddItems(bag, "herb", herbsOnGround) herbsOnGround = herbsOnGround - taken
If Inv.CountItem(bag, "potion") > 0
Inv.RemoveItems bag, "potion"
health = health + 20
EndIf
A chest is just another bag. MoveItems moves some of what's in one slot of
the first bag into the second: all of them, or none if they won't fit.
Local chest:Inv.Inventory = Inv.CreateInventory(catalog, 20) If Inv.MoveItems(bag, chest, 0, 3) = 0 Then Print Inv.LastError()
Make a list of changes, with negative numbers for what goes and positive ones
for what comes. ApplyChanges makes them all, or none of them if any would
fail, so a trade can never go half way.
Local changes:Inv.ItemDelta List = New List Local price:Inv.ItemDelta = New Inv.ItemDelta price.item = "herb" price.quantity = -2 Local potion:Inv.ItemDelta = New Inv.ItemDelta potion.item = "potion" potion.quantity = 1 changes.Add(price) changes.Add(potion) If Not Inv.ApplyChanges(bag, changes) Then Print Inv.LastError() Delete changes ; Deleting a list leaves its items, so delete them too. Delete price Delete potion
Ask for the slot's view. A view is lent to you, not given, so don't delete it, and ask again after the bag changes.
Local view:Inv.StackView = Inv.InventorySlot(bag, 0) Text 10, 10, catalog.Label(view.Item()) + " x" + view.Quantity()
CaptureInventory turns a bag into JSON, which SaveJsonAtomic
saves safely. RestoreInventory makes a new bag from it.
Local saved:Int = Inv.CaptureInventory(bag)
If Not SaveJsonAtomic(saved, "backpack.json") Then Print "Saving failed"
FreeJson saved
Local loaded:Int = LoadJson("backpack.json")
If loaded
Local restored:Inv.Inventory = Inv.RestoreInventory(catalog, loaded)
If restored = Null Then Print Inv.LastError()
FreeJson loaded
EndIf
Blip's tip
If slot >= 0 And Inv.EquipItem(bag, slot, "hand")looks safe, but Blitz works out both sides ofAnd, so it still callsEquipItemwhenslotis -1, and that stops the program. Check first, in anIfof its own, as the program above does.
Four more complete programs come with the module:
The reference below has every function and rule.
0.1.0 unpublished source preview. Requires Extended object-lifecycle-v1,
runtime 1114 / module ABI 5 or newer. No package dependencies. Items are plain
catalog definitions; the game owns its UI, notifications, saves and crafting.
Blitz3D+ comes with this module, so any Extended program can
Import blitz.inventory. Open quickstart.bb and run
it. Its item catalog is the module's own items.json, and every screen is
drawn in source.
Dialect "modern"
Import blitz.inventory As Inv
Local catalog:Inv.ItemCatalog = Inv.LoadItems("items.json") ; Your game's catalog.
If catalog = Null Then RuntimeError Inv.LastError()
Local bag:Inv.Inventory = Inv.CreateInventory(catalog, 2, 8D)
Local moved:Int = Inv.AddItems(bag, "herb", 20)
Local leftInWorld:Int = 20 - moved
Print leftInWorld
Delete bag
Delete catalog
End
Only remove moved items from your world pickup. AddItems and RemoveItems
may be partial; transfers, equipment changes, batches and reservations are atomic.
Slots are zero-based. Slot capacity is 1..65536; weight is a game-defined unit
per item. A weight limit of zero means unlimited, but accumulated weight must
remain finite. Equipment has its own named slots, outside backpack capacity,
and still contributes to weight.
{
"id": "my-game",
"version": 1,
"items": [
{"id":"herb", "label":"Herb", "stack":10, "weight":0.5, "equipment":[]},
{"id":"sword", "label":"Sword", "stack":1, "weight":3, "equipment":["hand"]}
]
}
All shown members are required; extra members are ignored. IDs and equipment
names are case-sensitive ASCII tokens of 1..128 characters from letters, digits,
_, . and -. IDs must be unique; equipment names must be unique within an
item. Labels are nonempty text. Version/stack must be positive JSON integers
fitting Int; weight must be a finite non-negative JSON number. Equipment must
have stack limit 1. Empty catalogs are valid. Catalog order, JSON whitespace and
labels never affect compatibility. Transfers between two bags need the same
catalog ID, version and stack/weight/equipment definitions. Saves instead check
each item they hold; see Candidate restore.
All resource constructors are private. Functions are qualified with Inv in
these examples. Methods use parentheses, for example claim.ReadItem(0, output).
The compiler does not support chaining a method after a function result: obtain
view = Inv.InventorySlot(bag, 0) first, then call view.Quantity().
| Declaration | Contract and example |
|---|---|
ItemDelta | Caller-filled item:Str, quantity:Int. New Inv.ItemDelta; no owned children. |
ItemCatalog | Validated definition owner; catalog.ID():Str, Version():Int, Label(item:Str):Str. Unknown label returns "". |
LoadItems(file:Str):ItemCatalog | Read offline UTF-8 JSON; caller owns result. Inv.LoadItems("items.json"). Null plus LastError on load/schema failure. |
ParseItems(document:Int):ItemCatalog | Borrow and copy caller JSON. Inv.ParseItems(document); caller can immediately FreeJson document. Same validation as LoadItems. |
CreateInventory(catalog, slots:Int=20, weightLimit:Double=0D):Inventory | Caller owns result; borrows catalog. Inv.CreateInventory(catalog, 12, 40D). Invalid dimensions/weight are programming errors. |
AddItems(bag, item:Str, quantity:Int=1):Int | Actual pickup, possibly partial. Inv.AddItems(bag, "herb", 12). Existing compatible stacks before empty slots, ascending index within each pass. |
RemoveItems(bag, item:Str, quantity:Int=1):Int | Actual removal, possibly partial. Inv.RemoveItems(bag, "herb", 2). Unequipped, unreserved units only, ascending slot order. |
MoveItems(source, destination, slot:Int, quantity:Int):Int | Requested quantity or zero, atomically. Inv.MoveItems(bag, chest, 0, 3). Requires matching catalog ID/version and stack/weight/equipment definitions; labels may differ. Same-owner call is a no-op returning zero. |
EquipItem(bag, slot:Int, equipmentSlot:Str):Int | True/False; Inv.EquipItem(bag, 1, "hand"). Swaps the previous item back into the bag using the vacated slot. Reserved items cannot equip. |
UnequipItem(bag, equipmentSlot:Str):Int | True/False; Inv.UnequipItem(bag, "hand"). Full bag leaves equipment intact. |
CountItem(bag, item:Str):Int | Available unequipped total, zero for unknown ID. Inv.CountItem(bag, "herb"); crafting inputs use this availability. |
InventorySlot(bag, slot:Int):StackView | Borrowed read-only view; view.Item():Str, Quantity():Int, Reserved():Int. Empty is "", zero, zero. Never Delete a view. |
InventorySlots(bag):Int | Backpack capacity, Inv.InventorySlots(bag); iterate 0 through result minus 1. |
InventoryWeight(bag):Double | Physical backpack plus equipment weight, including reservations. Inv.InventoryWeight(bag). |
EquipmentItem(bag, equipmentSlot:Str):Str | Equipped ID or empty for empty/unknown slot. Inv.EquipmentItem(bag, "hand"). |
InventoryRevision(bag):Long | Non-negative revision; Inv.InventoryRevision(bag). Mutation advances once, no-op/failed mutation never does. |
InventoryCatalog(bag):ItemCatalog | Borrowed catalog the bag was created or restored with. Inv.InventoryCatalog(bag) = catalog. Never Delete it through this result. |
ApplyChanges(bag, changes:ItemDelta List, expectedRevision:Long=-1L):Int | Atomic signed batch, True/False; see exchange example. -1 accepts current state. Empty/all-zero batch succeeds unchanged. |
CanApplyChanges(bag, changes:ItemDelta List, expectedRevision:Long=-1L):Int | Non-mutating True/False preview using the exact ApplyChanges planner, including reservations, freed slots, output weight and revision headroom. Inv.CanApplyChanges(bag, changes, revision). Preserves views, quantities, reservations and revision; copy LastError on failure. |
ReserveItems(bag, ingredients:ItemDelta List, expectedRevision:Long=-1L):Reservation | Claimed owned handle or Null. Strictly positive inputs, nonempty list. claim = Inv.ReserveItems(bag, ingredients). |
Reservation | ID():Long, Count():Int, ReadItem(index:Int, output:ItemDelta). Copies original positive ingredient totals, sorted by ID. Index is zero-based; methods remain valid after commit until deletion. ReadItem's implicit Int result is unused. |
CommitReservation(claim, outputs:ItemDelta List):Int | True/False. Non-negative outputs, empty list explicitly consumes inputs. Inv.CommitReservation(claim, outputs). Does not delete the handle. |
TakeReservation(bag, id:Long):Reservation | Claims a restored active handle exactly once. Inv.TakeReservation(candidate, savedID). Unknown/already claimed/completed returns Null plus LastError. No revision change. |
UnclaimedReservations(bag):Int | Number awaiting a restored owner. Inv.UnclaimedReservations(candidate) must be zero before paired installation. |
CaptureInventory(bag):Int | Caller-owned JSON, freed with FreeJson. snapshot = Inv.CaptureInventory(bag). Capture does not save a file or mutate/claim anything. |
RestoreInventory(catalog, snapshot:Int):Inventory | Borrow JSON; return owned independent candidate or Null plus LastError. Inv.RestoreInventory(catalog, snapshot). Every restored reservation is unclaimed. |
LastError():Str | Main-thread diagnostic. Copy immediately after failure. Cleared by fallible load/mutation/restore/claim calls, including successful calls. Queries and destructors do not clear it. |
Local changes:Inv.ItemDelta List = New List Local cost:Inv.ItemDelta = New Inv.ItemDelta cost.item = "herb" cost.quantity = -2 Local reward:Inv.ItemDelta = New Inv.ItemDelta reward.item = "potion" reward.quantity = 1 changes.Add(cost) changes.Add(reward) Local expected:Long = Inv.InventoryRevision(bag) If Not Inv.ApplyChanges(bag, changes, expected) Then Print Inv.LastError() Delete changes Delete cost Delete reward
This fragment assumes the catalog/bag are still live and contain those IDs.
Repeated IDs aggregate negative inputs and positive outputs separately.
Each gross total must fit positive Int; INT_MIN cannot be a single ingredient
quantity. Outputs cannot fund missing inputs in the same batch. Inputs free
slots and weight before outputs are placed in ID order. Every physical per-item
total, including equipment, is at most 2147483647; partial pickups stop at that
bound. Deleting a passed delta is a recoverable validation failure; a Null list
is a programming error.
Expected full bags/weight limits return zero for ordinary partial operations. Atomic failures return False/Null with LastError and preserve the whole state, including revision, reservation IDs and allocations. Unknown IDs, incompatible catalogs, malformed snapshots and stale revisions also produce diagnostics. Invalid resource references, slot indexes, negative ordinary quantities, invalid construction dimensions and invalid ReadItem outputs/indexes raise RuntimeError. Zero ordinary quantities do nothing. Unknown IDs are still validated at zero.
Reservations leave physical ingredients in their original slots, contributing
to both weight and stack capacity. Multiple reservations may share a stack.
Only unreserved portions may move/remove; merging more of the same item preserves
all allocations. Commit simulates this reservation's removal and outputs while
retaining other reservations. Output failure leaves inputs locked and permits
retry. A second commit fails. Active Delete claim releases availability in
place: no refund, extra slot, event allocation, callback or file write is needed.
| Resource | Ownership and teardown |
|---|---|
| Catalog | Game owns. Inventories borrow it; delete them first. |
| Inventory | Game owns. Owns slots, equipment, views and every registered active/completed reservation handle. Delete drains handles before storage. |
| Reservation | Caller controls claimed handle lifetime; inventory tracks/drains it. Delete before inventory when possible; delete a future Crafter before its inventory. Owner deletion invalidates aliases. |
| StackView | Borrowed, never Delete. Every successful content mutation invalidates all views; reacquire after it. Claims, captures and no-ops do not invalidate. |
| ItemDelta / input List | Caller owns both. Module borrows during call and copies necessary values. Deleting a List does not delete its items. |
| JSON input / capture | Module never deletes supplied handles. Capture returns caller ownership; use FreeJson. |
Assigning Null or leaving scope does not call destructors. Delete completed handles too; they intentionally retain copied ingredient totals until disposal. Inventory deletion discards game state; it never saves, awards items or commits work. There is no Stop/Cancel alias because inventory has no running controller: active reservation disposal is its specified infallible cancellation operation. Games still call Save and Commit explicitly when they need those actions.
The core is main-thread only, with no clock, callback, random state, rendering, input polling or event queue. No caller behavior interface is necessary. Games notify UI/quests after an operation returns; reentrancy into an in-progress mutation cannot occur through a module callback. Imports allocate nothing.
{"format":"blitz-inventory-v2", "catalog":"my-game", "version":1,
"weightLimit":40, "revision":12, "lastID":3,
"slots":[{"item":"herb","quantity":5}, {"item":"","quantity":0}],
"equipment":{"hand":"sword"},
"reservations":[{"id":3, "allocations":[{"slot":0,"quantity":2}]}]}
Format blitz-inventory-v2 stores the catalog ID and catalog version, weight
limit, ordered backpack slots, revision, last-issued ID and active reservations
with their precise slot allocations. Equipment is an object from slot name to
item ID, holding occupied slots only. Reserved totals and ingredient
descriptions are reconstructed and validated; they are not trusted independent
counters. IDs are positive Longs, never reused within a state lineage. Completed
handles are not persisted. JSON integers preserve Long IDs exactly.
Restore requires exactly blitz-inventory-v2 (0.1.0 preview v1 saves are
rejected) and the same catalog ID and version. It does not compare whole
catalogs. Every saved item must still exist and fit its current stack limit;
each equipped item must still be allowed in its named slot; the bag must fit its
weight limit at current weights. Failures name the item or slot, or report the
weight limit. Labels, item order and items the save does not hold may change
freely, and slot names added to the catalog restore empty. Bump the catalog
version when old saves need an explicit game migration.
Restore also rejects placement/quantity errors, duplicate IDs or slots within a reservation, over-reservation and invalid revisions. It never changes installed state. Missing expected claims, duplicate claims and orphan unclaimed handles are game-level paired-load failures:
Count/ReadItem to validate the expected ingredients for each job.Delete the candidate on any failure. A standalone game can take and delete
restored handles to cancel them. The reservation example performs this paired
installation. It captures in memory; it does not claim disk durability. Actual
file saving is an explicit game operation such as checked SaveJsonAtomic.
Revision wrap is prohibited. Each active reservation keeps one revision of headroom for cancellation; other mutations fail before consuming that space. At exhausted IDs/revisions, the game must migrate state; no automatic reset can make stale revisions look current. A separately restored timeline can share numeric IDs with its source: IDs are scoped to that inventory lineage, not UUIDs.
All accept --verify, execute their real action/drawing paths, assert results and
clean up before End. No placeholder drag control is shown.
The implementation favors reviewable scratch plans: O(slots + equipment) storage per mutation. Removal scans slots; transfers plan both bags; batches scan by catalog ID; reservations store only their allocated slots and copied totals. Adding to a stack tests a deterministic binary64 weight sum in at most 31 binary search steps. There is no tolerance which silently exceeds a weight limit. Successful paths allocate plans and changed views; this is not a zero-allocation per-frame API. Catalog insertion sorting is O(definitions²), performed once. Runtime JSON array/object edits copy values, making large captures costly. Use capture/restore for save/load work, not each frame.
Deferred: per-instance durability, nested bags, arbitrary item scripts, UI or
crafting adapters, worker/network synchronization and catalog migrations.
blitz.crafting builds its timed queue on these reservation APIs.
See license, provenance and
changelog.