Chance
local pool = Twill.Chance.new()
pool:AddItem("common", 100)pool:AddItem("legendary", 1, 2)
local prize = pool:Next(playerLuck)A weight is how often an entry should come up against the others, so nothing has
to add up to anything in particular. Weights of 75 and 25 and weights of 3
and 1 describe the same table.
Luck is an exponent, not a multiplier
Section titled “Luck is an exponent, not a multiplier”Each entry carries an exponent deciding how far it moves when luck is applied.
effective weight = weight * (1 + luck) ^ exponentThe default exponent is zero, which leaves an entry deaf to luck however high it goes. A positive one makes the entry likelier as luck rises, and a negative one makes it rarer.
That is what lets a single number shift a whole table. Give the common bulk an
exponent of zero, give the rare tail a positive one, and one luckFactor
expresses every luck potion, gamepass, and event bonus in the game without a
second table of odds to keep in step.
| Entry | Weight | Exponent | At luck 0 | At luck 3 |
|---|---|---|---|---|
common | 100 | 0 | 100 | 100 |
lucky | 10 | 1 | 10 | 40 |
unlucky | 10 | -1 | 10 | 2.5 |
Every function taking a luckFactor refuses one at or below -1. At -1 the
base is zero and below it the base is negative, either of which produces a
weight that is infinite or not a number, and no comparison downstream catches
that. Bad luck is spelled with a factor between -1 and 0.
The odds are computed, not kept
Section titled “The odds are computed, not kept”GetProbabilities answers what a table actually pays out, at any luck.
for item, probability in pool:GetProbabilities(playerLuck) do print(item, `{probability * 100}%`)endReading them from the pool rather than writing them into the interface means the odds shown to a player cannot drift from the odds being drawn. A table rebalanced without the display being updated is otherwise a silent, permanent lie.
Roblox requires the possible outcomes and their numerical odds to be shown before a player spends Robux, or currency bought with Robux, on a random result. Anything sold that improves those odds has to state its effect in numbers too, which is what reading the same pool at two luck factors gives you.
Read the paid random items policy rather than relying on this paragraph, since the wording is Roblox’s and it changes.
Where the draw comes from
Section titled “Where the draw comes from”The default source is an ordinary Random. That is right for variety nobody
would contest, and wrong for anything sold.
For a draw a player would argue about, pass a Random
round. The pool then draws from that round’s stream, so revealing the seed
afterwards lets anyone replay the outcome.
local round = Twill.Random.Commit()announce(round.Commitment)
local pool = Twill.Chance.new(round)pool:AddItem("common", 100)pool:AddItem("legendary", 1, 2)
local prize = pool:Next(playerLuck)
announce(round:Reveal())See Run a draw a player can audit for the full flow. Replaying requires the draws to be taken in the same order, and the entries to be added in the same order: a pool weighs its entries in the order they were added, so a replayed round lands on the same entry on any server.
Chance.new
Section titled “Chance.new”[Server] | [Client]
Creates an empty pool that draws from the given source.
function Chance.new<T>(source: Source?): Pool<T>
export type Source = Random | { NextNumber: (self: any) -> number }Parameters
| Name | Type | Description |
|---|---|---|
source | Source? | Where draws come from. A fresh Random when left out, which is not cryptographic. |
Returns
Pool<T> - An empty pool.
Throws when given anything but a Random, a table carrying NextNumber, or
nothing.
Pool:AddItem
Section titled “Pool:AddItem”[Server] | [Client]
Adds an entry, or replaces the one already held under it.
function Pool:AddItem(item: T, weight: number, luckExponent: number?)Parameters
| Name | Type | Description |
|---|---|---|
item | T | What to hand back when this entry is drawn. Anything but nil. |
weight | number | How often it should come up against the others. Zero or more, and finite. |
luckExponent | number? | How far luck moves it. Zero when left out, which leaves it deaf to luck. |
Throws on a nil item, and on a weight or exponent that is not a finite number.
A negative weight is refused, because a table holding one reports odds above one
for everything else.
An entry added twice keeps only the second weight, so re-adding is how an entry is retuned.
Pool:RemoveItem
Section titled “Pool:RemoveItem”[Server] | [Client]
Takes an entry out of the pool.
function Pool:RemoveItem(item: T)Removing something that was never there does nothing rather than failing.
Pool:Contains
Section titled “Pool:Contains”[Server] | [Client]
Reports whether the pool holds an entry.
function Pool:Contains(item: T): booleanReturns
boolean - true when the pool holds it.
Pool:Clear
Section titled “Pool:Clear”[Server] | [Client]
Takes every entry out, leaving the pool ready to be filled again.
function Pool:Clear()Pool:IsEmpty
Section titled “Pool:IsEmpty”[Server] | [Client]
Reports whether the pool holds nothing at all.
function Pool:IsEmpty(): booleanReturns
boolean - true when there is nothing held.
A pool holding entries that all weigh nothing is not empty, and still draws nothing.
Pool:GetWeight
Section titled “Pool:GetWeight”[Server] | [Client]
Returns what one entry is worth at the given luck.
function Pool:GetWeight(item: T, luckFactor: number?): number?Returns
number? - What it is worth, or nil when the pool does not hold it.
Pool:GetWeights
Section titled “Pool:GetWeights”[Server] | [Client]
Returns what every entry is worth at the given luck.
function Pool:GetWeights(luckFactor: number?): { [T]: number }Returns
{ [T]: number } - A fresh table, safe to keep.
Pool:GetTotalWeight
Section titled “Pool:GetTotalWeight”[Server] | [Client]
Returns what the whole pool is worth at the given luck.
function Pool:GetTotalWeight(luckFactor: number?): numberReturns
number - What the pool is worth altogether. Zero for an empty pool.
Throws when the entries together come to infinity, which an extreme exponent can reach.
The total is cached against the luck it was worked out at, and worked out again when an entry changes or a different luck is asked for. Reading the same pool at the same luck twice costs one walk, not two.
Pool:GetProbability
Section titled “Pool:GetProbability”[Server] | [Client]
Returns how likely one entry is at the given luck, as a fraction of one.
function Pool:GetProbability(item: T, luckFactor: number?): number?Returns
number? - How likely it is, or nil when the pool does not hold it. Zero when
the pool is worth nothing.
Pool:GetProbabilities
Section titled “Pool:GetProbabilities”[Server] | [Client]
Returns how likely every entry is at the given luck.
function Pool:GetProbabilities(luckFactor: number?): { [T]: number }Returns
{ [T]: number } - A fresh table, empty when there is nothing to draw.
This is the reading to show a player, and the one to disclose where the draw is sold. Taking it at two luck factors is how the effect of something that improves the odds is stated in numbers.
Pool:Next
Section titled “Pool:Next”[Server] | [Client]
Draws one entry, each as likely as its share of the pool.
function Pool:Next(luckFactor: number?): T?Parameters
| Name | Type | Description |
|---|---|---|
luckFactor | number? | How much luck to apply. None when left out. Above -1. |
Returns
T? - The entry drawn, or nil when there was nothing to draw.
An empty pool and a pool whose entries all weigh nothing both answer with nothing. An entry weighing nothing is never drawn.
tostring(pool)
Section titled “tostring(pool)”[Server] | [Client]
Chance<3 entries, 111 weight>The weight shown is the unaided total, before any luck is applied.
What this does not do
Section titled “What this does not do”No pity, and no guarantee after so many draws. That needs a counter per player
that survives a session, which is Data’s job rather than
this module’s. It is a handful of lines where the draw happens:
data.SinceLegendary += 1
if data.SinceLegendary >= 90 then data.SinceLegendary = 0 return "legendary"end
return pool:Next(playerLuck)No multi-draw. Ten pulls is a loop. Drawing without replacement is RemoveItem
inside one.
No weighting of its own for Random.Pick. That draws evenly by design; this
module is the weighted counterpart.