Skip to content

LootKit

LootKit rolls weighted loot tables entirely on the server. Entries yield items, nested tables, or nothing; PityAfter = N force-hits an entry on the Nth consecutive miss, with counters persisted per player in the profile. The design decision that defines it: LootKit has no network surface at all. There is no “open chest” intent — your game decides when a roll happens (an InteractionKit prompt, a QuestKit reward, a kill event) and calls the kit from server code. What the client sees is whatever landed in the InventoryKit bag via its INV_Sync push.

A loot table is Rolls independent draws over weighted Entries. Each draw:

  1. Pity check first (player rolls only). If any entry’s persisted miss counter has reached its PityAfter, that entry is the pick — no randomness involved.
  2. Weighted pick otherwise. Sum the weights, draw a uniform number in [0, total), walk the entries subtracting weights until the draw crosses zero. An entry’s chance is exactly Weight / total — weights are relative, not percentages.
  3. Advance the pity book. Every entry with PityAfter updates: the picked entry’s counter resets to 0, every other pity entry’s counter increments by 1.
  4. Resolve the entry. An Id entry adds Count of that item to the result bag. A Table entry rolls the named table in place — including that table’s own Rolls. A Nothing entry adds nothing.

Same-id results aggregate: three draws that each land Coin x5 come back as one { Id = "Coin", Count = 15 }. The returned list is sorted by id, so result rendering is stable.

Table entries compose: a chest rolls a rarity tier, the tier rolls the item pool. The nested table’s Rolls apply on every visit — a parent draw that lands on a Table = "Gems" entry where Gems has Rolls = 3 produces three gem draws. Depth caps at 8; exceeding it raises an error naming the offending table, which is what turns an accidental A -> B -> A cycle into a loud failure instead of a hang. Referencing a table name that does not exist fails immediately at attach, not on the first unlucky roll in production.

local Root = game:GetService("ServerScriptService").ChloeKernelServer
local LootKit = require(Root.Kits.LootKit)
local Loot = LootKit.attach(Kernel, {
Inventory = Inventory, -- the attached InventoryKit; required by award()
Tables = {
Chest = {
Rolls = 2, -- two independent draws per open
Entries = {
{ Weight = 70, Id = "Coin", Count = { 5, 20 } }, -- ranged count
{ Weight = 25, Table = "Herbs" }, -- nested table
{ Weight = 5, Id = "RareRelic", PityAfter = 30 },-- guaranteed within 31 opens
},
},
Herbs = {
Entries = {
{ Weight = 1, Id = "Sage" },
{ Weight = 1, Id = "Nightshade" },
},
},
CursedChest = {
Entries = {
{ Weight = 3, Table = "Chest" },
{ Weight = 1, Nothing = true }, -- an empty open is a legal outcome
},
},
},
})
Field Type Default Description
Weight number required Relative pick weight. Must be greater than 0 — asserted at attach.
Id string? Item id to yield. Should exist in the InventoryKit catalog if you award (unknown ids grant 0 there).
Count number or {min, max} 1 Fixed count, or a uniform integer range rolled per draw.
Table string? Nested table name, rolled in place of an item. Unknown names fail at attach.
Nothing boolean? An explicitly empty draw. Weighting “nothing” is how you tune drop chance separately from drop contents.
PityAfter number? Force-hit this entry after this many consecutive misses. Player rolls only.
Field Type Default Description
Rolls number or {min, max} 1 Draws per roll of this table. A range re-rolls per visit.
Entries { Entry } required Must be non-empty — asserted at attach.
Option Type Default Description
Tables { [string]: LootTable } required Every table, validated up front: non-empty entries, positive weights, resolvable Table references.
Inventory InventoryKit Required only by award(). roll/rollFor work without it.
Seed number? Seeds the kit’s Random. Same seed, same call sequence, same results — the spec suite runs on Seed = 7.
Rng Random? Bring your own Random instance; wins over Seed. Omit both for a nondeterministic stream.
Method Player Pity Grants items
roll(tableName) none never advances no — returns the list
rollFor(session, tableName) yes reads and advances the persisted counters no — returns the list
award(session, tableName) yes reads and advances yes — through InventoryKit, reports overflow

The split matters. roll exists for anything that is not a real drop — drop-rate displays, kill-feed previews, balance simulations. It carries no pity book, so a tuning script can roll a table ten thousand times without corrupting a single player’s drought counter:

-- Balance check: what do 10k chest opens look like?
local Totals = {}
for _ = 1, 10000 do
for _, Item in Loot:roll("Chest") do
Totals[Item.Id] = (Totals[Item.Id] or 0) + Item.Count
end
end

rollFor is the real drop without the delivery — use it when your game hands out the items itself (world pickups, a reward screen that grants on claim). award is rollFor plus delivery:

local Granted, Overflow = Loot:award(Session, "Chest")

Pity is the “guaranteed within N+1 opens” mechanic: an entry with PityAfter = 30 that has missed 30 consecutive draws is force-picked on the next one. The details:

  • Counters are per player, per entry. Keys are "TableName:EntryIndex" (e.g. "Chest:3"), stored in profile.Data.LootPity — or session.Data.LootPity when the session has no profile, in which case pity resets every visit.
  • A hit resets the counter to 0, whether the hit was forced or natural. Other pity entries in the same table each count the draw as one more miss.
  • Multi-draw tables advance pity per draw, not per call: a Rolls = 2 chest moves the counters twice per open.
  • roll() never touches counters — it passes no pity book at all.

Because counters persist in the profile, droughts survive rejoins and server hops, which is the entire point: a pity timer that resets on leave is a pity timer players eventually discover is fake.

award grants each rolled item through Inventory:grant, which fills existing stacks, then empty slots, and returns what fit. award splits the result accordingly and returns two lists:

local Granted, Overflow = Loot:award(Session, "Chest")
-- Granted: what landed in the bag
-- Overflow: what did not fit — already rolled, already counted by pity, NOT delivered
for _, Item in Overflow do
spawnGroundDrop(Session, Item.Id, Item.Count)
end

Overflow items are lost unless you handle them — the kit reports, it does not queue redelivery. Loot.Awarded carries both lists, so a single bus subscriber can own the overflow policy (ground drops, mail, a stash) for every loot source in the game. A full inventory does not stop the roll or refund the pity advancement: the player opened the chest; the chest happened.

All randomness flows through one Random instance. Seed (or an injected Rng) makes the whole kit reproducible: same seed, same sequence of calls, same drops — which is how the spec suite asserts exact outcomes. The stream is shared across tables and players on one server, so determinism is per call sequence, not per player. For cross-server agreement on “what is available this hour”, the right tool is ShopKit’s windowed rotation seeding, not a LootKit seed.

Member Description
LootKit.attach(kernel, options) -> kit Validates every table up front; asserts on empty entries, non-positive weights, unknown Table references.
kit:roll(tableName) -> items Roll with no player. Never advances pity. Returns { { Id, Count } } sorted by id, same-id draws aggregated. Asserts on unknown table names.
kit:rollFor(session, tableName) -> items Roll against the player’s persisted pity book (profile.Data.LootPity, or session.Data.LootPity without a profile).
kit:award(session, tableName) -> (granted, overflow) rollFor plus Inventory:grant per item. Asserts unless Inventory was passed at attach. Both returns are item lists.
Topic Payload When
Loot.Rolled tableName, items, session? After every roll (session is nil) or rollFor/award (session set). items is the aggregated, sorted list.
Loot.Awarded session, tableName, granted, overflow After award finishes granting. Subscribe here for drop toasts and the overflow policy.
  • No client intents, on purpose. An “open chest” intent would make “which table, when” a client claim. Wire loot to server-observed events instead: an InteractionKit chest prompt, an NPC death on the bus, a CraftingKit completion.
  • Weights are floats and relative. { 70, 25, 5 } and { 0.7, 0.25, 0.05 } are the same table. Extreme ratios work — the specs pit 1e9 against 1e-9 to isolate pity behavior — so express designer intent directly.
  • Rolls ranges re-roll per visit — a nested table with Rolls = { 1, 3 } draws a fresh count each time a parent lands on it.
  • Loot ids are not validated against the item catalog at attach. LootKit does not require an InventoryKit until award, so a typo’d Id rolls fine and grants 0. If your tables are only ever awarded, a boot-time sweep of your own (assert every Entry.Id exists in the item registry) turns that typo into a crash on deploy instead of silently empty chests.