Skip to content

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.

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

  1. 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.
  2. 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.
  3. Every entry with PityAfter updates its counter: the picked entry’s resets to 0 and every other pity entry’s goes up by 1.
  4. The picked entry resolves. An Id entry adds Count of that item to the result. A Table entry rolls the named table in place, including that table’s own Rolls. A Nothing entry 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.

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.

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 rollFor or award only when the kit’s Policy gate allows it: options.Policy, or kernel.Policy, which Policy sets when it attaches. The gate asks Policy:allows(session, "PaidRandomItems"), which is false while 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: rollFor returns {}, "PolicyRestricted" and award returns {}, {}, "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.

src/Server/Game/Config/Loot.luau
--!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 },
},
},
}
src/Server/Game/Services/EconomyService.luau
--!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 handler
function 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 > 0
end
function EconomyService.stop(self: any)
self.Inventory:destroy()
self.Policy:destroy()
end
return EconomyService
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.
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.
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.
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
end
end

rollFor 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 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 in profile.Data.LootPity, or in session.Data.LootPity when 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 = 2 chest 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.

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 nil
for _, Item in Overflow do
spawnGroundDrop(Session, Item.Id, Item.Count)
end

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

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.

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.

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.
  • 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 set 1e9 against 1e-9 to isolate pity.
  • Loot ids are not checked against the item catalog at attach, because LootKit does not need an InventoryKit until award. A misspelled Id rolls fine and lands in the overflow list. If your tables are only ever awarded, add a boot-time check that every Entry.Id exists in the item registry.

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