LootKit
Server-rolled weighted loot tables with nesting, deterministic seeds, per-player pity saved in the profile, and a Policy gate for paid random items.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Kits .LootKit - Since
- v0.5.0
- Source
- Kits/LootKit.luau
LootKit rolls weighted loot tables on the server. Entries yield items, nested tables or nothing, and PityAfter = N forces an entry on the Nth consecutive miss, with counters saved per player in the profile. Tables marked Paid = true roll only for players whose Policy allows paid random items. It is a Kit.attach kit. Rolled items are delivered through InventoryKit.
The decision that defines it: LootKit has no network surface. There is no “open chest” intent. Your game decides when a roll happens (an InteractionKit prompt, a QuestKit reward, a kill, a developer product) and calls the kit from server code. The client sees whatever landed in the bag through InventoryKit’s INV_Sync push.
Mental model
Section titled “Mental model”A loot table is Rolls independent draws over weighted Entries. Each draw:
- Pity comes first, on player rolls only. If an entry’s saved miss counter has reached its
PityAfter, that entry is the pick, with no randomness. - Otherwise the kit sums the weights, draws a uniform number from 0 to the total, and walks the entries subtracting weights until the draw reaches zero. An entry’s chance is
Weight / total: weights are relative, not percentages. - Every entry with
PityAfterupdates its counter: the picked entry’s resets to 0 and every other pity entry’s goes up by 1. - The picked entry resolves. An
Identry addsCountof that item to the result. ATableentry rolls the named table in place, including that table’s ownRolls. ANothingentry adds nothing.
Results with the same id add up: three draws of Coin x5 come back as one { Id = "Coin", Count = 15 }. The returned list is sorted by id, so rendering is stable.
Nesting
Section titled “Nesting”Table entries compose: a chest rolls a rarity tier, and the tier rolls the item pool. The nested table’s Rolls apply on every visit, so a parent draw that lands on Table = "Gems" where Gems has Rolls = 3 produces three gem draws. Depth is capped at 8. Going deeper raises an error naming the table, which turns an accidental A -> B -> A cycle into a loud failure instead of a hang. A reference to a table name that does not exist fails at attach, not on the first unlucky roll in production.
Paid tables and Policy
Section titled “Paid tables and Policy”Roblox requires experiences to respect PolicyService for paid random items: loot boxes, eggs or crates bought with Robux or with currency bought with Robux. Mark those tables Paid = true. A paid table:
- Cannot be rolled with
roll(), which has no player. It asserts. - Rolls through
rollFororawardonly when the kit’s Policy gate allows it:options.Policy, orkernel.Policy, which Policy sets when it attaches. The gate asksPolicy:allows(session, "PaidRandomItems"), which isfalsewhile the player’s policy restricts paid random items and also while the lookup has not finished. It fails closed. - On refusal, publishes
Loot.Refused(session, tableName, "PolicyRestricted"), advances no pity, and returns the reason:rollForreturns{}, "PolicyRestricted"andawardreturns{}, {}, "PolicyRestricted".
Only the table the player pays to open is gated. Its nested tables roll in place, so mark the top-level table, not the pools under it.
Without any Policy, the gate cannot run. The kit logs one warning and rolls paid tables for everyone. That is not compliant: a game that sells paid random items must attach Policy.
Receipts ties this to purchases. A Loot.Refused (or an Inventory.Overflow) for the buyer while a developer product grant runs declines the purchase: the profile rolls back and the receipt stays open, unless the grant returns true. Receipts also waits for the buyer’s Policy lookup before it runs the grant.
Defining tables
Section titled “Defining tables”--!strict-- Loot: LootKit tables. Server-only config, so clients never see the odds.
return { 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 draws }, }, Herbs = { Entries = { { Weight = 1, Id = "Sage" }, { Weight = 1, Id = "Nightshade" }, }, }, RobuxCrate = { Paid = true, -- bought with Robux: rolls only where Policy allows paid random items Entries = { { Weight = 90, Table = "Chest" }, { Weight = 10, Id = "Dragon", PityAfter = 20 }, }, },}--!strict-- EconomyService: the inventory, the loot tables and the Policy gate for paid crates.
local ServerScriptService = game:GetService("ServerScriptService")
local InventoryKit = require(ServerScriptService.ChloeKernelServer.Kits.InventoryKit)local LootKit = require(ServerScriptService.ChloeKernelServer.Kits.LootKit)local Policy = require(ServerScriptService.ChloeKernelServer.Policy)local InventoryConfig = require(script.Parent.Parent.Config.Inventory)local LootTables = require(script.Parent.Parent.Config.Loot)
local EconomyService = { Name = "EconomyService", Dependencies = { "DataService" },}
function EconomyService.init(self: any, kernel: any) self.Kernel = kernel self.Policy = Policy.attach(kernel) -- sets kernel.Policy, which Paid tables consult self.Inventory = InventoryKit.attach(kernel, InventoryConfig) self.Loot = LootKit.attach(kernel, { Tables = LootTables, Inventory = self.Inventory })end
-- Called from the chest prompt's server handlerfunction EconomyService.openChest(self: any, session: any): boolean local Granted, Overflow, Refused = self.Loot:award(session, "Chest") if Refused then return false end return #Granted > 0 or #Overflow > 0end
function EconomyService.stop(self: any) self.Inventory:destroy() self.Policy:destroy()end
return EconomyServiceEntry 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. For award, it should exist in the InventoryKit catalog: an unknown id grants nothing and reports as overflow. |
|
Count |
number or {min, max} |
1 |
A 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” tunes the drop chance separately from the drop contents. | |
PityAfter |
number? |
Force 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 is rolled again on every visit. |
Entries |
{ Entry } |
required | Must not be empty, asserted at attach. |
Paid |
boolean? |
nil |
A paid random item generator: per-player rolls behind the Policy gate. Anything other than a boolean errors 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, boolean Paid. |
Inventory |
InventoryKit | Required only by award(). roll and rollFor work without it. |
|
Policy |
Policy | kernel.Policy |
The gate for Paid tables. |
Seed |
number? |
Seeds the kit’s Random. Same seed and same call sequence give the same results; the specs run on Seed = 7. |
|
Rng |
Random? |
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 | Paid tables |
|---|---|---|---|---|
roll(tableName) |
none | never advances | no, returns the list | asserts |
rollFor(session, tableName) |
yes | reads and advances the saved counters | no, returns the list | behind the Policy gate |
award(session, tableName) |
yes | reads and advances | yes, through InventoryKit, and reports overflow | behind the Policy gate |
roll is for anything that is not a real drop: drop-rate displays, previews, balance simulations. It carries no pity counters, so a tuning script can roll a table ten thousand times without touching any player’s drought counter:
-- Balance check: what do 10,000 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 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.
Pity timers
Section titled “Pity timers”Pity is the “guaranteed within N + 1 draws” mechanic: an entry with PityAfter = 30 that has missed 30 consecutive draws is forced on the next one.
- Counters are per player and per entry. Keys are
"TableName:EntryIndex"(for example"Chest:3"), saved inprofile.Data.LootPity, or insession.Data.LootPitywhen the session has no profile, in which case pity resets every visit. - A hit resets the counter to 0, 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, and a refused paid roll advances nothing.
Because counters live in the profile, droughts survive rejoins and server hops. A pity timer that resets on leave is one players eventually find out 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 and returns two lists, plus the refusal reason for a refused paid roll:
local Granted, Overflow, Refused = Loot:award(Session, "Chest")-- Granted: what landed in the bag-- Overflow: what did not fit, rolled and counted by pity but not delivered-- Refused: "PolicyRestricted" for a refused Paid table, else nilfor _, Item in Overflow do spawnGroundDrop(Session, Item.Id, Item.Count)endOverflow items are lost unless you handle them: the kit reports and does not queue redelivery. Loot.Awarded carries both lists, so one Bus subscriber can own the overflow policy (ground drops, mail, a stash) for every loot source. InventoryKit also publishes Inventory.Overflow for each item that did not fit. A full bag does not stop the roll or undo the pity change: the player opened the chest.
Determinism
Section titled “Determinism”All randomness goes through one Random instance. Seed, or an injected Rng, makes the kit reproducible: same seed, same sequence of calls, same drops, which is how the specs assert 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”, use 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 and a non-boolean Paid. |
kit:roll(tableName) -> items |
Roll with no player. Never advances pity. Returns { { Id, Count } } sorted by id. Asserts on unknown and Paid tables. |
kit:rollFor(session, tableName) -> (items, reason?) |
Roll against the player’s saved pity counters. A refused Paid roll returns {}, "PolicyRestricted". |
kit:award(session, tableName) -> (granted, overflow, reason?) |
rollFor plus Inventory:grant per item. Asserts unless Inventory was passed at attach. A refused Paid roll returns {}, {}, "PolicyRestricted". |
The refusal reason is new in 0.9.0: the second return of rollFor and the third of award. Code that ignores extra returns keeps working, but a caller that reads an empty result as bad luck should check the reason first.
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Loot.Rolled |
tableName, items, session? |
After every roll (session nil) or accepted rollFor and award. items is the combined, sorted list. |
Loot.Awarded |
session, tableName, granted, overflow |
After award finishes granting. Drop notifications and the overflow policy subscribe here. |
Loot.Refused |
session, tableName, reason |
A Paid roll was refused. reason is "PolicyRestricted". Receipts declines a purchase grant that caused it. |
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 events the server observes.
- Weights are relative numbers.
{ 70, 25, 5 }and{ 0.7, 0.25, 0.05 }are the same table. Extreme ratios work; the specs set1e9against1e-9to isolate pity. - Loot ids are not checked against the item catalog at attach, because LootKit does not need an InventoryKit until
award. A misspelledIdrolls fine and lands in the overflow list. If your tables are only ever awarded, add a boot-time check that everyEntry.Idexists in the item registry.