Skip to content

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.

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.
  • Equipped holds slot indices, not items. Equipping never moves or copies anything — it records a reference (Primary = 5 means “the item in slot 5”). That is why move has to update equip references when slots shuffle, and why draining a stack to zero has to auto-unequip it.
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 ownsgrant, 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.

attach takes the full item catalog up front. Ids are the keys; everything else has a default:

local Root = game:GetService("ServerScriptService").ChloeKernelServer
local 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,
},
},
})
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.
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.luau
local Root = game:GetService("ServerScriptService").ChloeKernelServer
local 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

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

Fills existing stacks first, then empty slots, scanning slots 1..MaxSlots in order for openings. Returns the count actually granted:

-- MaxSlots = 2, Potion.Stack = 10
Inventory:grant(Session, "Potion", 7) -- 7: slot 1 = 7
Inventory:grant(Session, "Potion", 7) -- 7: 3 top up slot 1, 4 open slot 2
Inventory:grant(Session, "Potion", 99) -- 6: capacity is 2 slots x 10 = 20
Inventory:grant(Session, "Mystery", 1) -- 0: unknown definition

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

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 + 3
Inventory:take(Session, "Ore", 9) -- false: nothing changes
Inventory:take(Session, "Ore", 6) -- true: 2 remain

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

move(session, from, to) is the only rearrangement primitive, and the whole drag-and-drop surface compiles down to it:

  1. Refused when from == to, either index is outside 1..MaxSlots, or the source slot is empty. Returns false, nothing changes.
  2. 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.
  3. 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 x9
Inventory:move(Session, 1, 2) -- merge: slot 2 = 10, slot 1 = 3

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

equip(session, slotIndex) succeeds only when the slot holds an item whose definition names an Equip slot. The sequence:

  1. Fire Inventory.CanEquip with Session, Id, and SlotName in the context. A handler returning false vetoes — nothing changes.
  2. Displace the incumbent. If the equip slot already has a holder, it is unequipped first: its OnUnequip runs and Inventory.Unequipped publishes. The displaced item stays exactly where it is in the bag — unequipping is dropping a reference, not moving an item.
  3. Record the reference, run the new item’s OnEquip, publish Inventory.Equipped, push one INV_Sync.
Inventory:grant(Session, "Sword", 1) -- slot 1
Inventory:grant(Session, "Axe", 1) -- slot 2, both Equip = "Primary"
Inventory:equip(Session, 1) -- Sword equipped
Inventory:equip(Session, 2) -- Sword's OnUnequip runs, then Axe's OnEquip
local Item, Index = Inventory:equipped(Session, "Primary") -- Axe, 2
Inventory: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.

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.

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
end
end, 60) -- after the kit's own gate at 50

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

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.

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.
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.
  • Attach order matters for persistence. The store lives inside profile data, so the DataDriver must be attached and loading profiles for Persist = true bags to save. The kit does not assert this — a missing profile falls back to session.Data silently only when Persist = false; with Persist = true and no profile it also writes to session.Data (the container check is Persist and session.Profile), which means a bag used before the profile loads is a different bag. Grant on onSession after your data driver, not before.
  • One kit per networked kernel. The kit has no Intents option — every attach on a kernel with networking wires the fixed INV_* channel names, so a second attach crashes on the already-defined channels. A second bag with a different Field (persistent stash plus session loadout) needs a kit attached against a facade kernel exposing only Hooks and Bus — that bag works fully but has no client intents or INV_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.