Skip to content

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.

Each entry carries an exponent deciding how far it moves when luck is applied.

effective weight = weight * (1 + luck) ^ exponent

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

EntryWeightExponentAt luck 0At luck 3
common1000100100
lucky1011040
unlucky10-1102.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.

GetProbabilities answers what a table actually pays out, at any luck.

for item, probability in pool:GetProbabilities(playerLuck) do
print(item, `{probability * 100}%`)
end

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

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.

[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

NameTypeDescription
sourceSource?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.

[Server] | [Client]

Adds an entry, or replaces the one already held under it.

function Pool:AddItem(item: T, weight: number, luckExponent: number?)

Parameters

NameTypeDescription
itemTWhat to hand back when this entry is drawn. Anything but nil.
weightnumberHow often it should come up against the others. Zero or more, and finite.
luckExponentnumber?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.

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

[Server] | [Client]

Reports whether the pool holds an entry.

function Pool:Contains(item: T): boolean

Returns

boolean - true when the pool holds it.

[Server] | [Client]

Takes every entry out, leaving the pool ready to be filled again.

function Pool:Clear()

[Server] | [Client]

Reports whether the pool holds nothing at all.

function Pool:IsEmpty(): boolean

Returns

boolean - true when there is nothing held.

A pool holding entries that all weigh nothing is not empty, and still draws nothing.

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

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

[Server] | [Client]

Returns what the whole pool is worth at the given luck.

function Pool:GetTotalWeight(luckFactor: number?): number

Returns

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.

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

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

[Server] | [Client]

Draws one entry, each as likely as its share of the pool.

function Pool:Next(luckFactor: number?): T?

Parameters

NameTypeDescription
luckFactornumber?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.

[Server] | [Client]

Chance<3 entries, 111 weight>

The weight shown is the unaided total, before any luck is applied.

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.