InventoryKit
Server-authoritative items with a definition registry, slotted stacking bags, equip slots, use handlers and a Tool bridge, where no client message can create an item.
- Realm
- Server + Client
- Require
ServerScriptService.ChloeKernelServer .Kits .InventoryKit - Since
- v0.4.0
- Source
- Kits/InventoryKit.luauNet/InventoryClient.luau
InventoryKit is the item model the other economy kits build on: LootKit grants into it, CraftingKit takes and grants through it, and ShopKit sells out of it. It is a definition registry plus a slotted bag per player with stacking, equip slots and use handlers, saved in the player’s profile. It is a Kit.attach kit, called from the owning service’s init. The client half, InventoryClient, keeps the local player’s latest bag from boot.
The decision that defines it: grant and take are server API only. No network channel creates or destroys items. Clients get four intents (move, equip, unequip, use), and each one re-derives legality from the server’s own store before touching anything.
Mental model
Section titled “Mental model”The store
Section titled “The store”Each player’s bag is one table, created on first access:
{ Slots = { ["1"] = { Id = "Potion", Count = 7 }, ["5"] = { Id = "Sword", Count = 1 }, }, Equipped = { Primary = 5 },}- Slot keys are strings. A bag with items in slots 1 and 5 keyed by integers would be a sparse array, and sparse arrays do not survive encoding into a DataStore. String keys make the store a plain map.
Equippedholds slot indices, not items. Equipping records a reference (Primary = 5means “the item in slot 5”) and never moves or copies anything. That is whymoveupdates equip references when slots change, and why draining a stack to zero unequips it.
An Item is Id plus Count. There is no per-item state such as durability.
A bag that exists without one of the two tables, such as a profile default of Inventory = {}, gets the missing table on first access. Before 0.9.1 that default left Slots nil, so count, space and grant threw and the INV_* validators rejected every intent.
Where it lives
Section titled “Where it lives”Persist |
Container | Lifetime |
|---|---|---|
true (default) |
session.Profile.Data[Field] |
Saved by the DataDriver with the rest of the profile. |
false |
session.Data[Field] |
Ends with the session: battle-royale loadouts, per-match pickups. |
Field defaults to "Inventory". Two kits with different Field values give one player independent bags, with one constraint on intents covered in the design notes.
Before the profile loads
Section titled “Before the profile loads”With Persist = true, a DataDriver loads the profile after the session starts, and session.ProfilePending is true in between. The kit does not refuse grants in that window. A grant made before the profile exists lands in a bag in session.Data[Field]. When the profile loads, the kit empties that bag and grants each of its slots into the profile bag in slot order, using the normal stacking rules. Items that no longer fit publish Inventory.Overflow and are lost. Equip state from the early bag is not carried over. Then, with or without an early bag, it sends the owner the loaded bag, because whatever the client saw before the load was not the saved one.
Before 0.9.0 these early grants were written to session data and discarded when the profile arrived. A starter item handed out in onSession, or a purchase delivered while the profile loaded, now reaches the save.
The merge subscribes through Milestones.on(kernel, "Kernel.ProfileLoaded", fn) (Milestones), so a kit attached after a player’s profile loaded still merges and sends that player’s bag. Before 0.9.1 it was a plain Bus subscription that missed those profiles, and the after-load send happened only when session data had been touched first.
The server creates, clients arrange
Section titled “The server creates, clients arrange”Every path that changes what a player owns (grant, take) is a plain Luau method with no network exposure. Loot drops, quest rewards, shop purchases and crafting outputs all call it from server code. The client surface is rearrangement plus one read: the owning client receives an INV_Sync state push after every change, when the session starts and once the profile loads, and can ask for its own bag with the INV_Fetch request. Inventory UI is a render problem: draw the last snapshot, fire intents, wait for the next snapshot. There is nothing to predict and nothing to reconcile.
Defining items
Section titled “Defining items”attach takes the full item catalog. Ids are the keys, and every field has a default:
--!strict-- Inventory: InventoryKit options, the item catalog and its Tool templates.
local ServerStorage = game:GetService("ServerStorage")
local function heal(session: any, amount: number): boolean local Character = session.Player.Character local Humanoid = Character and Character:FindFirstChildOfClass("Humanoid") if not Humanoid or Humanoid.Health <= 0 or Humanoid.Health >= Humanoid.MaxHealth then return false end Humanoid.Health = math.min(Humanoid.Health + amount, Humanoid.MaxHealth) return trueend
return { Items = { Sword = { Stack = 1, Kind = "Weapon", Equip = "Primary", Meta = { Damage = 25 }, }, Potion = { Stack = 10, Kind = "Consumable", OnUse = function(session: any, _item: any, _kit: any): boolean? return heal(session, 25) -- true consumes one; full health keeps it end, }, WarHorn = { OnUse = function(_session: any) -- Rally nearby allies. No return: reusable, nothing is consumed return nil end, }, }, -- Equipping a Sword hands the player a clone of this Tool Tools = { Sword = ServerStorage.Tools.Sword },}ItemDef fields
Section titled “ItemDef fields”| Field | Type | Default | Description |
|---|---|---|---|
Name |
string? |
the id | Display name. |
Stack |
number? |
1 |
Maximum count per slot. |
Kind |
string? |
Game taxonomy ("Weapon", "Potion"). The kit never reads it; your UI and hooks do. |
|
Equip |
string? |
nil |
Equip slot name ("Primary", "Belt"). nil means not equippable. |
OnUse |
(session, item, kit) -> boolean? |
Use handler. Return true to consume one from the stack; any other return leaves it. It receives the kit, so a handler can grant and take (a bag that unpacks into items). It may yield: the slot stays locked until it returns. |
|
OnEquip |
(session, item) -> () |
Runs after the item becomes the slot’s holder. | |
OnUnequip |
(session, item) -> () |
Runs whenever the item stops being equipped: an explicit unequip, displacement by another equip, or its stack draining to zero. | |
Meta |
table? |
Static definition data (damage, heal amounts), shared by every item of that id. |
attach options
Section titled “attach options”| Option | Type | Default | Description |
|---|---|---|---|
Items |
{ [string]: ItemDef } |
required | The definition registry. |
MaxSlots |
number? |
30 |
Bag size. Slot indices run from 1 to MaxSlots. attach asserts 1 <= MaxSlots <= 255. |
Persist |
boolean? |
true |
true stores in session.Profile.Data, false in session.Data. |
Field |
string? |
"Inventory" |
Key inside the container. |
Intents |
boolean? |
true |
Wire the four INV_* intents, the INV_Fetch request and INV_Sync. Set false on a second bag attached to the same kernel, since channels are defined once. |
Tools |
{ [string]: Tool }? |
nil |
Item id to a Tool template for equippable items. Keep templates in ServerStorage. See The Tool bridge. |
attach also defines the two hook points and, when the kernel has networking (kernel:net() exists), wires the intents, the INV_Fetch request and the INV_Sync state channel. A kernel without networking (specs, offline tools) gets a working kit with no wire surface.
--!strict-- EconomyService: the inventory and its starter kit.
local ReplicatedStorage = game:GetService("ReplicatedStorage")local ServerScriptService = game:GetService("ServerScriptService")
local InventoryConfig = require(script.Parent.Parent.Config.Inventory)local InventoryKit = require(ServerScriptService.ChloeKernelServer.Kits.InventoryKit)local Logger = require(ReplicatedStorage.ChloeKernel.Logger)local Milestones = require(ServerScriptService.ChloeKernelServer.Milestones)
local Log = Logger.scope("EconomyService")
local EconomyService = { Name = "EconomyService", Dependencies = { "DataService" },}
function EconomyService.init(self: any, kernel: any) self.Kernel = kernel self.Inventory = InventoryKit.attach(kernel, InventoryConfig)end
function EconomyService.start(self: any) -- Milestones.on also replays profiles that loaded before this subscription self.LoadedConnection = Milestones.on(self.Kernel, "Kernel.ProfileLoaded", function(session: any, profile: any) if not profile.Data.StarterKit then profile.Data.StarterKit = true self.Inventory:grant(session, "Potion", 3) end end) self.OverflowConnection = self.Kernel.Bus:subscribe("Inventory.Overflow", function(_topic, session, id, count) Log.warn(`{session.Player.Name}: {count} {id} did not fit`) end)end
function EconomyService.stop(self: any) if self.LoadedConnection then self.LoadedConnection:disconnect() end if self.OverflowConnection then self.OverflowConnection:disconnect() end self.Inventory:destroy()end
return EconomyService--!strict-- InventoryController: keeps the local player's bag from boot, so screens built later read it at once.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local InventoryClient = require(ReplicatedStorage.ChloeKernel.Net.InventoryClient)
local InventoryController = { Name = "InventoryController",}
function InventoryController.init(self: any, _kernel: any) -- Kit channels are not in Game.Channels: InventoryClient makes its own NetClient (never pass self.Net) self.Bag = InventoryClient.new()end
function InventoryController.start(_self: any) end
function InventoryController.stop(self: any) if self.Bag then self.Bag:destroy() endend
return InventoryController--!strict-- Inventory screen: renders the bag InventoryController keeps; clicking a slot uses its item.-- Expects StarterGui.Inventory with a Grid frame of TextButtons named 1 to 30.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)
local SlotCount = 30
local Inventory = {}
function Inventory.new(context: any, gui: ScreenGui): any local Scope = context.Kernel:createScope() local Bag = context.Controllers("InventoryController").Bag local UseItem = NetClient.new():intent("INV_Use", { "NumberU8" }) local Buttons: { [number]: TextButton } = {} local Grid = gui:FindFirstChild("Grid") for Index = 1, SlotCount do local Cell = Grid and Grid:FindFirstChild(tostring(Index)) if Cell and Cell:IsA("TextButton") then Buttons[Index] = Cell Scope:add(Cell.Activated:Connect(function() UseItem.fire(Index) -- the server decides; the next INV_Sync is the truth end)) end end local function render(store: any) for Index, Button in Buttons do local Slot = store.Slots[tostring(Index)] Button.Text = if Slot then `{Slot.Id} x{Slot.Count}` else "" end end -- Every push and fetch lands here: no polling, no local bookkeeping Scope:add(Bag:onChanged(render)) local Current = Bag:get() if Current then render(Current) -- the controller has held it since boot, even if this gui arrived at first spawn else task.spawn(function() Bag:fetch() -- nothing arrived yet: ask; the reply reaches render through onChanged end) end return { Modal = false, StartsOpen = true, open = function() gui.Enabled = true end, close = function() gui.Enabled = false end, destroy = function() Scope:destroy() end, }end
return InventoryA drag handler fires INV_Move(from, to) the same way, and equip buttons fire INV_Equip(slotIndex) and INV_Unequip(slotName).
The client never edits its own copy after firing an intent. If the move was legal, the next INV_Sync shows it. If it was not, the intent died in the fail-closed chain and no snapshot arrives. A UI that renders snapshots cannot drift from the server.
Granting and taking
Section titled “Granting and taking”grant(session, id, count?) -> granted
Section titled “grant(session, id, count?) -> granted”Fills existing stacks of that id first, then empty slots from 1 to MaxSlots in order. Returns the count that fit:
-- MaxSlots = 2, Potion.Stack = 10Inventory:grant(Session, "Potion", 7) -- 7: slot 1 = 7Inventory:grant(Session, "Potion", 7) -- 7: 3 top up slot 1, 4 open slot 2Inventory:grant(Session, "Potion", 99) -- 6: capacity is 2 slots x 10 = 20Inventory:grant(Session, "Mystery", 1) -- 0: unknown definitionTopping up before opening slots keeps repeated small grants from fragmenting the bag. A partial grant is not an error, it is the overflow signal: whatever did not fit, including every unit of an unknown id, publishes Inventory.Overflow(session, id, remainder). Inventory.Granted and the sync publish only when at least one item landed.
Receipts listens for that topic. An Inventory.Overflow for the buyer while a product grant runs declines the purchase: the profile rolls back and the receipt stays open for redelivery, unless the grant returns true.
space(session, id) -> number
Section titled “space(session, id) -> number”How many of id fit right now, counted the way grant fills: the room left in existing stacks of that id plus a full stack for every empty slot. Unknown ids fit nowhere (0). Check it before a sale or a trade instead of granting and cleaning up the overflow.
take(session, id, count?) -> boolean
Section titled “take(session, id, count?) -> boolean”All or nothing. If the spendable total across all stacks is below count, nothing moves and it returns false. Otherwise it drains stacks until satisfied, deletes emptied slots, and unequips any emptied slot that was equipped (running its OnUnequip):
Inventory:grant(Session, "Ore", 8) -- stacks of 5 + 3Inventory:take(Session, "Ore", 9) -- false: nothing changesInventory:take(Session, "Ore", 6) -- true: 2 remainAll-or-nothing makes take safe as a cost: crafting inputs and shop sales never partly charge.
count(session, id) -> number
Section titled “count(session, id) -> number”The spendable total across all stacks. A slot whose OnUse is still running holds back the one unit in use, so count leaves it out and take never removes it. A sale, a craft or a trade cannot spend the potion a player is drinking.
Moving items: the drag-and-drop primitive
Section titled “Moving items: the drag-and-drop primitive”move(session, from, to) is the only rearrangement primitive:
- It refuses when
from == to, either index is outside 1 toMaxSlots, the source slot is empty, or either slot is locked by a runningOnUse. It returnsfalseand nothing changes. - It merges when the target holds the same id below its stack limit: as much as fits moves, the rest stays behind, and an emptied source slot is deleted.
- It swaps otherwise, including into an empty slot and between two same-id slots when the target stack is full.
-- Rock.Stack = 10; slots 1 and 2 hold Rock x4 and Rock x9Inventory:move(Session, 1, 2) -- merge: slot 2 = 10, slot 1 = 3After the contents settle, equip references follow. On a swap, an Equipped entry that pointed at from now points at to, and one that pointed at to now points at from. On a merge, an equipped target keeps its equip, and an equipped source that empties hands its equip to the target, which holds the same item. A sword stays equipped wherever the player drags it, and no OnUnequip or OnEquip fires, because the item never stopped being equipped.
Before 0.9.1 a merge remapped references like a swap, so merging a stack onto an equipped stack of the same item moved the equip to the emptied source slot: OnUnequip never ran and the Tool stayed held.
Equipping
Section titled “Equipping”equip(session, slotIndex) succeeds only when the slot holds an item whose definition names an Equip slot:
Inventory.CanEquipfires withSession,IdandSlotName. A handler returningfalsevetoes and nothing changes.- The current holder of that equip slot, if any, is unequipped: its
OnUnequipruns andInventory.Unequippedpublishes. The displaced item stays where it is in the bag. - The reference is recorded, the new item’s
OnEquipruns, a Tool clone is handed out whenToolshas a template for it,Inventory.Equippedpublishes and oneINV_Syncgoes out.
Inventory:grant(Session, "Sword", 1) -- slot 1Inventory:grant(Session, "Axe", 1) -- slot 2, both Equip = "Primary"Inventory:equip(Session, 1) -- Sword equippedInventory:equip(Session, 2) -- Sword's OnUnequip runs, then Axe's OnEquiplocal Item, Index = Inventory:equipped(Session, "Primary") -- Axe, 2Inventory:unequip(Session, "Primary")Every way out of an equip slot (explicit unequip, displacement, a stack drained by take or use) runs the same OnUnequip, so cleanup written there runs once for all of them.
The Tool bridge
Section titled “The Tool bridge”With Tools = { [itemId] = ToolTemplate }, equip slots drive Roblox Tools:
- Equipping an item that has a template clones it into the player’s current
Backpackand has the Humanoid hold it withHumanoid:EquipTool. - Unequipping, displacement, or losing the item through
takeorusedestroys the clone. - The engine replaces the Backpack on every spawn. On
CharacterAddedthe kit destroys the old clones and hands out fresh ones for everything equipped. It looks the Backpack up each time and never caches it. - A profile that loads after the character spawned hands out clones for what the profile has equipped.
- A character holds one Tool at a time. With several equipped tools, clones go out in equip-slot name order, the last one is held, and the others wait in the Backpack.
- When the session ends, the respawn watcher disconnects and every clone is destroyed.
Tool.Activated stays client input. The combat kits keep their own channels, so a sword Tool is the visual and the input binding while MeleeKit decides hits.
Using items
Section titled “Using items”use(session, slotIndex) requires the item to define OnUse:
- The slot is locked. The lock is taken before the hook because both the hook and
OnUsemay yield, and a secondINV_Usefor the same slot must not start until the first settles. Inventory.CanUsefires withSession,IdandSlotIndex.falsereleases the lock and refuses.OnUse(session, item, kit)runs. When it returns (or errors), the lock is released. An error propagates to the caller and publishes nothing.Inventory.Usedpublishes.- Only a literal
truereturn consumes. The kit finds the slot again by identity, since atakeor a profile load may have changed the bag whileOnUseyielded, and removes one unit. A slot that empties is deleted and unequipped throughOnUnequip.
While the slot is locked, move refuses it, the INV_Use validator rejects it, and count and take hold back one unit. A yielding handler (a drink animation) cannot be used twice, cannot drive a count negative, and cannot consume an item that moved onto its index.
Potion = { Stack = 3, Equip = "Belt", OnUse = function(session, item, kit) if not healCharacter(session, 25) then return false -- full health: keep it end return true end,},Inventory.Used publishes on every completed use, consumed or not. Cooldown UI, analytics and QuestKit objectives subscribe there.
Client intents
Section titled “Client intents”All four intents go through the fail-closed chain with a kit validator at priority 50. A rejected or erroring validator means the handler never runs. Each validator re-derives legality from the server’s store: the client contributes indices and names, never facts.
| Intent | Schema | Rate limit | The priority 50 validator checks |
|---|---|---|---|
INV_Move |
NumberU8, NumberU8 |
20 per second | Both indices in 1 to MaxSlots, and from ~= to. |
INV_Equip |
NumberU8 |
10 per second | The slot holds an item whose definition has an Equip slot. |
INV_Unequip |
String |
10 per second | That equip slot name currently has a holder. |
INV_Use |
NumberU8 |
10 per second | The slot holds an item whose definition has OnUse, and the slot is not locked. |
The handlers then call move, equip, unequip and use, the same methods server code calls, which run their own checks and hooks again. The API is safe on its own, and the chain is a cheap early reject (see rate limiting for what spam costs).
INV_Fetch is a request with no arguments and a { "Any" } response, rate-limited to 2 per second with a burst of 4. Its handler returns the caller’s own store and nothing else, so it has nothing to authorize. A no-op validator at priority 50 on Request.INV_Fetch keeps it off the fail-closed reject path without marking it Open, so the security audit does not list it. After destroy() it answers nil. Added in 0.9.1.
Game rules go in extra validators, or in the fail-open hooks when the rule should also govern server calls:
Kernel.Hooks:on("Intent.INV_Use", function(context) if context.Session.Data.Stunned then return false -- no potions while stunned end return nilend, 60) -- after the kit's own validator at 50INV_Sync: UI is a render problem
Section titled “INV_Sync: UI is a render problem”INV_Sync is a state channel (schema { "Any" }) carrying the owner’s whole store, the Slots and Equipped table shown above. It is pushed once when the session starts, after every change (grant, take, move, equip, unequip, use), and once the profile loads. Only the owning player receives it.
A push reaches only listeners that exist when it lands. A screen whose ScreenGui arrives at first spawn is built after the session-start and profile-load pushes, and before 0.9.1 such a screen never got the bag until the next change. So a screen does not listen to INV_Sync itself: a controller creates an InventoryClient at boot, and the screen reads its cache. Server code that writes the store table directly calls kit:sync(session) afterwards to re-send it.
There is no per-field delta. The store is small (30 slots is a few hundred bytes), and a full snapshot keeps the client stateless. With hundreds of slots and heavy churn that stops being cheap; consider a Replica for the hot fields.
InventoryClient
Section titled “InventoryClient”InventoryClient (ReplicatedStorage.ChloeKernel.Net.InventoryClient) is the client half, added in 0.9.1. InventoryClient.new(netClient?) connects to INV_Sync at once through NetClient:onState and keeps the latest bag. With no argument it makes a NetClient of its own; pass one to share it. A controller’s self.Net is the Registry handle table, not a NetClient, and passing it errors with InventoryClient.new takes a NetClient (NetClient.new()) or nothing, not the Registry handle table. Before the Unreleased fix after 0.9.1 the argument was required. Create it in a controller’s init, as in Usage. Because it builds the channel on the spot, frames that reached the client before it existed park and replay to it, within Packet’s limits of 64 frames and 10 seconds per channel (see frame parking).
- Every push replaces the cached bag and fires the
onChangedlisteners with it. A payload that is not a table is ignored. get()returns the cache without a request:niluntil the first push or fetch.fetch()yields for anINV_Fetchround trip and returns the latest bag. A push that lands while the request is in flight is newer than the reply, so the reply is discarded. A rejected or timed-out request keeps the cache and returns it, which isnilif nothing has arrived.
The Bag type is the store: { Slots: { [string]: { Id: string, Count: number } }, Equipped: { [string]: number } }. Treat it as read-only; changes go through the intents.
Both hook points are fail-open: an erroring handler does not veto. The intent chain is a security boundary, and errors there must reject. CanEquip and CanUse carry game rules (tournament weapon locks, silence debuffs), and a bug in one game rule should not lock every inventory on the server. Only an explicit return false vetoes.
| Hook point | Context | Fired by |
|---|---|---|
Inventory.CanEquip |
Session, Id, SlotName |
equip, before anything changes. |
Inventory.CanUse |
Session, Id, SlotIndex |
use, after the slot is locked and before OnUse. |
Kernel.Hooks:on("Inventory.CanEquip", function(context) if TournamentMode and context.SlotName == "Primary" then return false end return nilend)equip and use fire these on every path, client intent or server call, so a rule installed here cannot be skipped by game code that forgot to check.
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Inventory.Granted |
session, id, count |
After a grant that placed at least one item. count is what fit. |
Inventory.Overflow |
session, id, count |
Part or all of a grant did not fit, or the id is unknown. count is the amount that was not placed. |
Inventory.Taken |
session, id, count |
After a successful take. |
Inventory.Equipped |
session, slotName, id |
After an item becomes a slot’s holder. |
Inventory.Unequipped |
session, slotName, id |
On every unequip path. |
Inventory.Used |
session, id, slotIndex |
After OnUse returned, consumed or not. |
Inventory.Changed |
session |
On every change, just before the INV_Sync push. |
API reference
Section titled “API reference”| Member | Description |
|---|---|
InventoryKit.attach(kernel, options) -> kit |
Defines the hook points, wires intents when networking exists, subscribes the pre-profile merge and the Tool bridge. See options. |
kit:grant(session, id, count?) -> number |
Tops up stacks, then opens slots. Returns the count granted. The rest publishes Inventory.Overflow. count defaults to 1. |
kit:take(session, id, count?) -> boolean |
All-or-nothing removal of spendable units. Emptied equipped slots unequip. |
kit:count(session, id) -> number |
Spendable total. A slot mid-use holds back one unit. |
kit:space(session, id) -> number |
How many of id fit right now. 0 for unknown ids. |
kit:move(session, from, to) -> boolean |
Swap or merge. Equip references follow. Refuses locked slots. |
kit:equip(session, slotIndex) -> boolean |
Equip with displacement, gated by Inventory.CanEquip. |
kit:unequip(session, slotName) -> boolean |
Drops the reference and runs OnUnequip. false when nothing is equipped there. |
kit:equipped(session, slotName) -> (Item?, number?) |
The holding item and its slot index. |
kit:use(session, slotIndex) -> boolean |
Locks the slot, runs OnUse behind Inventory.CanUse, consumes on true. false when the slot is empty, has no OnUse, is locked, or was vetoed. |
kit:items(session) -> store |
The live store table. Read it; do not write it. |
kit:sync(session) |
Publishes Inventory.Changed and sends the owner the current store over INV_Sync. For code that edited the store table directly; the kit’s own methods already sync. Added in 0.9.1. |
kit:destroy() |
Unhooks the four intent validators and the INV_Fetch validator and makes their handlers inert, disconnects the session, profile and respawn listeners, destroys every Tool clone, and clears the use locks. |
InventoryClient API
Section titled “InventoryClient API”| Member | Description |
|---|---|
InventoryClient.new(netClient?) -> client |
Connects to INV_Sync and prepares the INV_Fetch request on the given NetClient, or on one of its own when netClient is nil. Errors on anything that is not a NetClient, such as the Registry handle table. Create it at client boot. Added in 0.9.1. |
client:get() -> Bag? |
The latest bag, or nil before the first push or fetch. Added in 0.9.1. |
client:fetch() -> Bag? |
Yields for INV_Fetch and returns the latest bag. A push that lands during the request wins over the reply. Errors after destroy(). Added in 0.9.1. |
client:onChanged(fn(bag)) -> connection |
Runs fn with each new bag, from a push or a fetch. The connection has disconnect(). Added in 0.9.1. |
client:destroy() |
Disconnects the INV_Sync listener and destroys the change signal. Added in 0.9.1. |
Design notes
Section titled “Design notes”- A second bag needs
Intents = false. Pass it on a secondattachagainst the same kernel (a persistent stash next to a session loadout) to skip the fixedINV_*channel names. That bag works fully but has no client intents,INV_FetchorINV_Sync, so InventoryClient never sees it; drive and render it through your own channels. - Equip displacement never overflows the bag, because equipping never occupies a slot.
Metais per definition, not per item. Model per-item state as distinct ids, or keep it in your own data keyed by slot.