InventoryKit
InventoryKit is the item model the other economy kits build on — LootKit grants into it, CraftingKit takes and grants through it, ShopKit charges against it. It is a definition registry plus a slotted per-player bag with stacking, equip slots, and use handlers, persisted through the player’s profile. The design decision that defines it: grant and take are server-API-only. There is no network channel that creates or destroys items. Clients get exactly four intents — move, equip, unequip, use — and every one of them re-derives legality from the server’s own books before touching anything.
Mental model
Section titled “Mental model”The store
Section titled “The store”Each player’s bag is one table, created lazily on first access:
{ Slots = { ["1"] = { Id = "Potion", Count = 7 }, ["5"] = { Id = "Sword", Count = 1 }, }, Equipped = { Primary = 5 },}Two things to internalize:
- 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 JSON encoding into a DataStore. String keys make the store a plain map that serializes losslessly.
Equippedholds slot indices, not items. Equipping never moves or copies anything — it records a reference (Primary = 5means “the item in slot 5”). That is whymovehas to update equip references when slots shuffle, and why draining a stack to zero has to auto-unequip it.
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] |
Dies with the session — battle-royale loadouts, per-match pickups |
Field defaults to "Inventory". Two kits with different Field values give one player independent bags (a persistent stash plus a session-only loadout, for example) — with an intent-wiring constraint covered in the design notes.
The doctrine: the server mints, clients arrange
Section titled “The doctrine: the server mints, 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, crafting outputs all call it from server code. The client-facing surface is rearrangement only, and the owning client receives an INV_Sync state push after every mutation, plus one on session start. Inventory UI is purely 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 up front. Ids are the keys; everything else has a default:
local Root = game:GetService("ServerScriptService").ChloeKernelServerlocal InventoryKit = require(Root.Kits.InventoryKit)
local Inventory = InventoryKit.attach(Kernel, { MaxSlots = 30, Items = { Sword = { Stack = 1, Kind = "Weapon", Equip = "Primary", Meta = { Damage = 25 }, OnEquip = function(session, item) giveTool(session, "Sword") end, OnUnequip = function(session, item) removeTool(session, "Sword") end, }, Potion = { Stack = 10, Kind = "Consumable", OnUse = function(session, item, kit) healCharacter(session, 25) return true -- consumed: one leaves the stack end, }, WarHorn = { OnUse = function(session) rallyNearbyAllies(session) -- no return: reusable, nothing is consumed end, }, },})ItemDef fields
Section titled “ItemDef fields”| Field | Type | Default | Description |
|---|---|---|---|
Name |
string? |
the id | Display name. |
Stack |
number? |
1 |
Max 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 the stack alone. Receives the kit instance, so a use handler can grant/take (an item that unpacks into other items). |
OnEquip |
(session, item) -> () |
— | Runs after the item becomes the slot’s holder. |
OnUnequip |
(session, item) -> () |
— | Runs whenever the item stops being equipped — explicit unequip, displacement by another equip, or its stack draining to zero. |
Meta |
table? |
— | Static definition data (damage, heal amounts). Definition-level, not per-instance — every Sword shares one Meta. |
attach options
Section titled “attach options”| Option | Type | Default | Description |
|---|---|---|---|
Items |
{ [string]: ItemDef } |
required | The definition registry. Granting an id not in it is a no-op that returns 0. |
MaxSlots |
number? |
30 |
Bag size. Slot indices run 1..MaxSlots. |
Persist |
boolean? |
true |
true stores in session.Profile.Data, false in session.Data. |
Field |
string? |
"Inventory" |
Key inside the container. |
attach also defines the two hook points and — when the kernel has networking (kernel:net() exists) — wires the four intents and the INV_Sync state channel. A kernel without networking (specs, offline tools) gets a fully functional kit with no wire surface.
-- src/Server/Bootstrap.luaulocal Root = game:GetService("ServerScriptService").ChloeKernelServerlocal InventoryKit = require(Root.Kits.InventoryKit)
return function(kernel) local Inventory = InventoryKit.attach(kernel, { Items = { Sword = { Stack = 1, Equip = "Primary", Meta = { Damage = 25 } }, Potion = { Stack = 10, OnUse = function(session) healCharacter(session, 25) return true end }, }, })
-- Item flow is server code calling server methods: kernel:onSession(function(session) local Granted = Inventory:grant(session, "Potion", 3) if Granted < 3 then warn(session.Player.Name, "bag full:", 3 - Granted, "starter potions dropped") end end)
kernel.Bus:subscribe("Inventory.Used", function(_, session, id, slotIndex) print(session.Player.Name, "used", id, "from slot", slotIndex) end)end-- src/Client/Bootstrap.luaulocal ChloeKernel = game:GetService("ReplicatedStorage").ChloeKernellocal NetClient = require(ChloeKernel.Net.Client)
return function(_kernel) local Net = NetClient.new()
-- The whole inventory UI renders from this one push. It arrives on -- join and after every mutation — no polling, no local bookkeeping. Net:onState("INV_Sync", { "Any" }, function(store) renderInventory(store.Slots, store.Equipped) end)
local MoveItem = Net:intent("INV_Move", { "NumberU8", "NumberU8" }) local EquipItem = Net:intent("INV_Equip", { "NumberU8" }) local UnequipSlot = Net:intent("INV_Unequip", { "String" }) local UseItem = Net:intent("INV_Use", { "NumberU8" })
onSlotDragged(function(from, to) MoveItem.fire(from, to) -- the server decides; the next INV_Sync is the truth end) onSlotDoubleClicked(function(slotIndex) UseItem.fire(slotIndex) end)endThe client never updates 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 the snapshot never changes. A UI that renders snapshots cannot desync.
Granting and taking
Section titled “Granting and taking”grant(session, id, count?) -> granted
Section titled “grant(session, id, count?) -> granted”Fills existing stacks first, then empty slots, scanning slots 1..MaxSlots in order for openings. Returns the count actually granted:
-- 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 definitionTop-up-then-open is why repeated small grants do not fragment the bag: partial piles fill before any new slot is claimed. A partial grant is not an error — it is the overflow signal. Callers that care check the return value; LootKit’s award and CraftingKit’s outputs both build their overflow reporting on it.
Inventory.Granted publishes only when at least one item landed. A grant of 0 (unknown id, full bag) publishes nothing and sends no sync.
take(session, id, count?) -> boolean
Section titled “take(session, id, count?) -> boolean”All-or-nothing. If the player’s total across all stacks is below count, nothing moves and it returns false. Otherwise it drains stacks until satisfied, deletes emptied slots, and auto-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 is what makes take safe as a cost: crafting inputs and trade escrow never partially charge. Check-then-take on one server thread cannot interleave, which is exactly how CraftingKit uses it.
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, and the whole drag-and-drop surface compiles down to it:
- Refused when
from == to, either index is outside 1..MaxSlots, or the source slot is empty. Returnsfalse, nothing changes. - Merge when the target holds the same id below its stack limit: as much as fits transfers, the remainder stays behind, an emptied source slot is deleted.
- Swap otherwise — including into an empty slot, and including two same-id slots when the target stack is already full.
-- Rock.Stack = 10; slots hold Rock x4 and Rock x9Inventory:move(Session, 1, 2) -- merge: slot 2 = 10, slot 1 = 3After the slot contents settle, equip references follow: any Equipped entry pointing at from now points at to and vice versa. Your sword stays equipped no matter where the player drags it — no OnUnequip/OnEquip fires, because the item never stopped being equipped; only its address changed.
Equipping
Section titled “Equipping”equip(session, slotIndex) succeeds only when the slot holds an item whose definition names an Equip slot. The sequence:
- Fire
Inventory.CanEquipwithSession,Id, andSlotNamein the context. A handler returningfalsevetoes — nothing changes. - Displace the incumbent. If the equip slot already has a holder, it is unequipped first: its
OnUnequipruns andInventory.Unequippedpublishes. The displaced item stays exactly where it is in the bag — unequipping is dropping a reference, not moving an item. - Record the reference, run the new item’s
OnEquip, publishInventory.Equipped, push oneINV_Sync.
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")Displacement means game code never writes “unequip the old thing first” — one equip call is always a complete, consistent transition, and every exit path (explicit unequip, displacement, stack drained by take or use) funnels through the same OnUnequip. Put your cleanup there once and it runs for all of them.
Using items
Section titled “Using items”use(session, slotIndex) requires the item to define OnUse. It fires Inventory.CanUse (Session, Id, SlotIndex in the context; false vetoes), then calls the handler with (session, item, kit).
Consume-on-true: only a literal true return decrements the stack. Anything else — nil, false, no return — leaves the count alone, which is how reusable items (horns, tools, toggles) share the same channel as consumables. When consumption empties the slot, the slot is deleted and auto-unequipped through OnUnequip — a belt potion drained to zero leaves the belt.
Potion = { Stack = 3, Equip = "Belt", OnUse = function(session, item, kit) if not healCharacter(session, 25) then return false -- full health: refuse to waste it end return true end,},Inventory.Used publishes on every successful use, consumed or not — subscribe there for cooldown UI, analytics, or QuestKit objectives.
Client intents
Section titled “Client intents”All four intents ride the fail-closed chain: the kit installs its own validator at priority 50, and a rejected or crashing validator means the handler never runs. Each validator re-derives legality from the server’s store — the client’s message contributes indices and names, never facts:
| Intent | Schema | Rate limit | The priority-50 validator re-derives |
|---|---|---|---|
INV_Move |
NumberU8, NumberU8 |
20/s | Both indices in 1..MaxSlots, from ~= to. |
INV_Equip |
NumberU8 |
10/s | The slot holds an item whose definition has an Equip slot. |
INV_Unequip |
String |
10/s | That equip slot name currently has a holder. |
INV_Use |
NumberU8 |
10/s | The slot holds an item whose definition has OnUse. |
The handlers then call move/equip/unequip/use — the same methods server code calls, which run their own checks and hooks again. Validation is not something the intent layer does instead of the API; the API is safe on its own and the chain is a cheap early reject (see rate limiting for what spam costs).
Add your own game rules as extra validators, or use the fail-open hooks below when the rule should also govern server-initiated calls:
Kernel.Hooks:on("Intent.INV_Use", function(context) if context.Session.Data.Stunned then return false -- no potions while stunned endend, 60) -- after the kit's own gate 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 same Slots/Equipped table documented above. It is pushed:
- once on session start (so UI has a snapshot before the player opens anything), and
- after every mutation: grant, take, move, equip, unequip, use.
Only the owning player receives it. There is no per-field delta and no partial update: the store is small (30 slots is a few hundred bytes) and a full snapshot makes the client stateless. If you run bags at hundreds of slots with high churn, that trade stops being free — consider a replica for the hot fields instead.
Both hook points are declared with FailOpen = true: a crashing handler does not veto. This is deliberately the opposite of the intent chain. The intent chain is a security boundary — errors there must reject. CanEquip/CanUse carry game rules (tournament weapon locks, silence debuffs), and a bug in a game rule should not brick every inventory in 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 — before the handler runs. |
Kernel.Hooks:on("Inventory.CanEquip", function(context) if TournamentMode and context.SlotName == "Primary" then return false endend)Because equip and use fire these on every path — client intent or server call — a rule installed here cannot be bypassed by game code that forgot to check.
Bus topics
Section titled “Bus topics”Everything the kit does is observable on the bus; nothing requires importing the kit:
| Topic | Payload | When |
|---|---|---|
Inventory.Granted |
session, id, count |
After a grant that placed at least one item. count is the amount that actually fit, not the amount requested. |
Inventory.Taken |
session, id, count |
After a successful take. count is the requested amount (equal to the removed amount — takes are all-or-nothing). |
Inventory.Equipped |
session, slotName, id |
After an item becomes a slot’s holder. |
Inventory.Unequipped |
session, slotName, id |
On every unequip path: explicit, displacement, stack drained. |
Inventory.Used |
session, id, slotIndex |
After OnUse ran — consumed or not. |
Inventory.Changed |
session |
On every mutation, before the INV_Sync push. The coarse “something happened” signal — autosave triggers, dirty flags. |
API reference
Section titled “API reference”| Member | Description |
|---|---|
InventoryKit.attach(kernel, options) -> kit |
Registers hook points, wires intents when networking exists. See options. |
kit:grant(session, id, count?) -> number |
Top-up then open slots. Returns the granted count; 0 for unknown ids or a full bag. count defaults to 1. |
kit:take(session, id, count?) -> boolean |
All-or-nothing removal across stacks. Emptied equipped slots auto-unequip. |
kit:count(session, id) -> number |
Total across all stacks. |
kit:move(session, from, to) -> boolean |
Swap or merge; equip references follow. |
kit:equip(session, slotIndex) -> boolean |
Equip with displacement; gated by Inventory.CanEquip. |
kit:unequip(session, slotName) -> boolean |
Drop the reference; 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 |
Run OnUse; gated by Inventory.CanUse; consume on true. |
kit:items(session) -> store |
The live store table (Slots, Equipped). Read-only by convention — direct writes skip hooks, bus topics, and the sync push. |
Design notes
Section titled “Design notes”- Attach order matters for persistence. The store lives inside profile data, so the DataDriver must be attached and loading profiles for
Persist = truebags to save. The kit does not assert this — a missing profile falls back tosession.Datasilently only whenPersist = false; withPersist = trueand no profile it also writes tosession.Data(the container check isPersist and session.Profile), which means a bag used before the profile loads is a different bag. Grant ononSessionafter your data driver, not before. - One kit per networked kernel. The kit has no
Intentsoption — every attach on a kernel with networking wires the fixedINV_*channel names, so a second attach crashes on the already-defined channels. A second bag with a differentField(persistent stash plus session loadout) needs a kit attached against a facade kernel exposing onlyHooksandBus— that bag works fully but has no client intents orINV_Sync; drive and render it through your own channels. - Displaced items are never dropped. Equip displacement cannot overflow the bag because equipping never occupies a slot. Kits with real “hands” (tools in workspace) implement that in
OnEquip/OnUnequip.