Skip to content

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.

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.
  • Equipped holds slot indices, not items. Equipping records a reference (Primary = 5 means “the item in slot 5”) and never moves or copies anything. That is why move updates 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.

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.

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.

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.

attach takes the full item catalog. Ids are the keys, and every field has a default:

src/Server/Game/Config/Inventory.luau
--!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 true
end
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 },
}
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.
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.

src/Server/Game/Services/EconomyService.luau
--!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

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.

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 = 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

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

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.

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

All-or-nothing makes take safe as a cost: crafting inputs and shop sales never partly charge.

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.

move(session, from, to) is the only rearrangement primitive:

  1. It refuses when from == to, either index is outside 1 to MaxSlots, the source slot is empty, or either slot is locked by a running OnUse. It returns false and nothing changes.
  2. 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.
  3. 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 x9
Inventory:move(Session, 1, 2) -- merge: slot 2 = 10, slot 1 = 3

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

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

  1. Inventory.CanEquip fires with Session, Id and SlotName. A handler returning false vetoes and nothing changes.
  2. The current holder of that equip slot, if any, is unequipped: its OnUnequip runs and Inventory.Unequipped publishes. The displaced item stays where it is in the bag.
  3. The reference is recorded, the new item’s OnEquip runs, a Tool clone is handed out when Tools has a template for it, Inventory.Equipped publishes and one INV_Sync goes out.
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")

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.

With Tools = { [itemId] = ToolTemplate }, equip slots drive Roblox Tools:

  • Equipping an item that has a template clones it into the player’s current Backpack and has the Humanoid hold it with Humanoid:EquipTool.
  • Unequipping, displacement, or losing the item through take or use destroys the clone.
  • The engine replaces the Backpack on every spawn. On CharacterAdded the 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.

use(session, slotIndex) requires the item to define OnUse:

  1. The slot is locked. The lock is taken before the hook because both the hook and OnUse may yield, and a second INV_Use for the same slot must not start until the first settles.
  2. Inventory.CanUse fires with Session, Id and SlotIndex. false releases the lock and refuses.
  3. OnUse(session, item, kit) runs. When it returns (or errors), the lock is released. An error propagates to the caller and publishes nothing.
  4. Inventory.Used publishes.
  5. Only a literal true return consumes. The kit finds the slot again by identity, since a take or a profile load may have changed the bag while OnUse yielded, and removes one unit. A slot that empties is deleted and unequipped through OnUnequip.

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.

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

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 (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 onChanged listeners with it. A payload that is not a table is ignored.
  • get() returns the cache without a request: nil until the first push or fetch.
  • fetch() yields for an INV_Fetch round 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 is nil if 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 nil
end)

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.

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.
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.
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.
  • A second bag needs Intents = false. Pass it on a second attach against the same kernel (a persistent stash next to a session loadout) to skip the fixed INV_* channel names. That bag works fully but has no client intents, INV_Fetch or INV_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.
  • Meta is per definition, not per item. Model per-item state as distinct ids, or keep it in your own data keyed by slot.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.