Inventory

Bags, item stacks and equipment slots, with safe transfers and reservations.

Commands

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

Functions

Types

Guide

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.

A backpack holding herbs in two slots, with a sword in the hand slot

Try it

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.

Describe your items

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

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.

How do I...

Pick something up from the world?

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

Use something up?

If Inv.CountItem(bag, "potion") > 0
    Inv.RemoveItems bag, "potion"
    health = health + 20
EndIf

Move things into a chest?

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()

Buy something, swapping several items at once?

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

Show what's in a slot?

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()

Save a bag and load it again?

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 of And, so it still calls EquipItem when slot is -1, and that stops the program. Check first, in an If of its own, as the program above does.

Good to know

More examples

Four more complete programs come with the module:

The reference below has every function and rule.

Reference

Needs
No other modules
Examples
containers.bb, exchange.bb, quickstart.bb, reservations.bb

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.

Start with a pickup

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.

Catalog JSON

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

Public API

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().

DeclarationContract and example
ItemDeltaCaller-filled item:Str, quantity:Int. New Inv.ItemDelta; no owned children.
ItemCatalogValidated definition owner; catalog.ID():Str, Version():Int, Label(item:Str):Str. Unknown label returns "".
LoadItems(file:Str):ItemCatalogRead offline UTF-8 JSON; caller owns result. Inv.LoadItems("items.json"). Null plus LastError on load/schema failure.
ParseItems(document:Int):ItemCatalogBorrow and copy caller JSON. Inv.ParseItems(document); caller can immediately FreeJson document. Same validation as LoadItems.
CreateInventory(catalog, slots:Int=20, weightLimit:Double=0D):InventoryCaller owns result; borrows catalog. Inv.CreateInventory(catalog, 12, 40D). Invalid dimensions/weight are programming errors.
AddItems(bag, item:Str, quantity:Int=1):IntActual 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):IntActual removal, possibly partial. Inv.RemoveItems(bag, "herb", 2). Unequipped, unreserved units only, ascending slot order.
MoveItems(source, destination, slot:Int, quantity:Int):IntRequested 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):IntTrue/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):IntTrue/False; Inv.UnequipItem(bag, "hand"). Full bag leaves equipment intact.
CountItem(bag, item:Str):IntAvailable unequipped total, zero for unknown ID. Inv.CountItem(bag, "herb"); crafting inputs use this availability.
InventorySlot(bag, slot:Int):StackViewBorrowed read-only view; view.Item():Str, Quantity():Int, Reserved():Int. Empty is "", zero, zero. Never Delete a view.
InventorySlots(bag):IntBackpack capacity, Inv.InventorySlots(bag); iterate 0 through result minus 1.
InventoryWeight(bag):DoublePhysical backpack plus equipment weight, including reservations. Inv.InventoryWeight(bag).
EquipmentItem(bag, equipmentSlot:Str):StrEquipped ID or empty for empty/unknown slot. Inv.EquipmentItem(bag, "hand").
InventoryRevision(bag):LongNon-negative revision; Inv.InventoryRevision(bag). Mutation advances once, no-op/failed mutation never does.
InventoryCatalog(bag):ItemCatalogBorrowed 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):IntAtomic 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):IntNon-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):ReservationClaimed owned handle or Null. Strictly positive inputs, nonempty list. claim = Inv.ReserveItems(bag, ingredients).
ReservationID():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):IntTrue/False. Non-negative outputs, empty list explicitly consumes inputs. Inv.CommitReservation(claim, outputs). Does not delete the handle.
TakeReservation(bag, id:Long):ReservationClaims a restored active handle exactly once. Inv.TakeReservation(candidate, savedID). Unknown/already claimed/completed returns Null plus LastError. No revision change.
UnclaimedReservations(bag):IntNumber awaiting a restored owner. Inv.UnclaimedReservations(candidate) must be zero before paired installation.
CaptureInventory(bag):IntCaller-owned JSON, freed with FreeJson. snapshot = Inv.CaptureInventory(bag). Capture does not save a file or mutate/claim anything.
RestoreInventory(catalog, snapshot:Int):InventoryBorrow JSON; return owned independent candidate or Null plus LastError. Inv.RestoreInventory(catalog, snapshot). Every restored reservation is unclaimed.
LastError():StrMain-thread diagnostic. Copy immediately after failure. Cleared by fallible load/mutation/restore/claim calls, including successful calls. Queries and destructors do not clear it.

Batches and failures

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.

Reservation invariant and ownership

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.

ResourceOwnership and teardown
CatalogGame owns. Inventories borrow it; delete them first.
InventoryGame owns. Owns slots, equipment, views and every registered active/completed reservation handle. Delete drains handles before storage.
ReservationCaller controls claimed handle lifetime; inventory tracks/drains it. Delete before inventory when possible; delete a future Crafter before its inventory. Owner deletion invalidates aliases.
StackViewBorrowed, never Delete. Every successful content mutation invalidates all views; reacquire after it. Claims, captures and no-ops do not invalidate.
ItemDelta / input ListCaller owns both. Module borrows during call and copies necessary values. Deleting a List does not delete its items.
JSON input / captureModule 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.

Candidate restore

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

  1. Restore a candidate inventory.
  2. Restore dependent queues using their saved reservation IDs, taking each once.
  3. Use Count/ReadItem to validate the expected ingredients for each job.
  4. Require every queue to succeed and UnclaimedReservations to equal zero.
  5. Only then delete old dependants/inventory and install the complete candidate.

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.

Examples and performance

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.