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.
Mental model
Section titled “Mental model”A loot table is Rolls independent draws over weighted Entries. Each draw:
- 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. - 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 exactlyWeight / total— weights are relative, not percentages. - Advance the pity book. Every entry with
PityAfterupdates: the picked entry’s counter resets to 0, every other pity entry’s counter increments by 1. - Resolve the entry. An
Identry addsCountof that item to the result bag. ATableentry rolls the named table in place — including that table’s ownRolls. ANothingentry 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.
Nesting
Section titled “Nesting”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.
Defining tables
Section titled “Defining tables”local Root = game:GetService("ServerScriptService").ChloeKernelServerlocal 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 }, }, },})Entry fields
Section titled “Entry fields”| 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. |
LootTable fields
Section titled “LootTable fields”| 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. |
attach options
Section titled “attach options”| 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. |
The three entry points
Section titled “The three entry points”| 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 endendrollFor 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 timers
Section titled “Pity timers”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 inprofile.Data.LootPity— orsession.Data.LootPitywhen 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 = 2chest 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.
Awarding and overflow
Section titled “Awarding and overflow”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 deliveredfor _, Item in Overflow do spawnGroundDrop(Session, Item.Id, Item.Count)endOverflow 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.
Determinism
Section titled “Determinism”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.
API reference
Section titled “API reference”| 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. |
Bus topics
Section titled “Bus topics”| 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. |
Design notes
Section titled “Design notes”- 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 pit1e9against1e-9to isolate pity behavior — so express designer intent directly. Rollsranges re-roll per visit — a nested table withRolls = { 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’dIdrolls fine and grants 0. If your tables are only ever awarded, a boot-time sweep of your own (assert everyEntry.Idexists in the item registry) turns that typo into a crash on deploy instead of silently empty chests.