[Open Source] RollEngine - One Roll System for Loot, Pets, Traits, and More

RollEngine is a reusable Roblox probability engine where rolling, pity, modifiers, odds display, and testing all use the same rules and state model.

Define rarities, weighted items, and optional pity rules once. For each request, your game supplies the current state, modifiers, and random source, then receives the selected result and a fresh NextState to save.

Get RollEngine

See it in use

RollEngine is used in Delinquent Online.

It has no DataStore, currency, reward, UI, or Player dependency, so the same engine can power:

  • Loot crates and enemy drops
  • Pets, eggs, and companions
  • Character, class, style, and trait rolls
  • Fishing, mining, and gathering rewards
  • Cosmetics and event rewards
  • Random encounters or procedural choices

Why use it?

Random reward systems often begin as a few math.random() calls. They become difficult to maintain once luck boosts, pity, weighted items, odds displays, saving, and tests are added.

RollEngine keeps that probability logic in one place:

  • One source of truth: Roll() and GetOdds() use the same distribution, including pity, modifiers, exclusions, item weights, and normalization.
  • Transaction-friendly: the supplied state is never mutated. Commit the cost, reward, and returned NextState together, or discard the attempted transaction.
  • Pity and temporary modifiers: add soft pity, one hard-pity rule, and multiple named counters. Target luck boosts, events, exclusions, and other changes by ID or reusable group tag.
  • Exact next-roll odds: GetOdds() reports the exact distribution for the supplied state and modifiers without consuming randomness.
  • Deterministic testing: the same rules, state, modifiers, and random draw values produce the same result, which helps reproduce bugs and boundary cases.
  • Fail-fast validation: malformed static rules are rejected when the engine is created, while invalid roll requests return structured errors.
  • Balance tooling: empirical simulation measures long-run behavior, while AutoTune searches for an approximate multiplier to investigate and verify.
  • Reusable two-level weighting: configure rarity weights and independent item weights once, then use the engine for loot, pets, traits, encounters, and more.

RollEngine owns only its compiled rules. It has no background tasks, Player connections, hidden player-state tables, or global random source. It decides what was rolled, while your game handles permission, cost, rewards, persistence, and presentation.

A complete small example

In this example, RollEngine is a child of the server Script.

--!strict

local RollEngine = require(script.RollEngine)

local LootEngine = RollEngine.new({
	Rarities = {
		{
			Id = "Common",
			Weight = 75,
			Items = {
				{ Id = "Coins", Weight = 3 },
				{ Id = "Potion", Weight = 1 },
			},
		},
		{
			Id = "Rare",
			Weight = 20,
			Items = {
				{ Id = "Sword", Weight = 3 },
				{ Id = "Cape", Weight = 1 },
			},
		},
		{
			Id = "Legendary",
			Weight = 5,
			Tags = { "LuckAffected" },
			Items = {
				{ Id = "Dragon", Weight = 1 },
			},
		},
	},

	PityRules = {
		{
			Id = "LegendaryPity",
			ResetWhen = { RarityIds = { "Legendary" } },
			HardPity = {
				AtRoll = 10,
				ForceRarityId = "Legendary",
			},
		},
	},
})

local State = LootEngine:GetDefaultState()
local RandomGenerator = Random.new()

local Response = LootEngine:Roll({
	State = State,
	Random = RandomGenerator,
})

if Response.Success then
	local Result = Response.Result
	State = Result.NextState

	print(Result.ItemId, Result.RarityId)
else
	warn(Response.Error.Code, Response.Error.Message)
end

That is the entire rolling flow:

  1. Create an engine from rules.
  2. Load or create the caller-owned state.
  3. Pass the state into Roll().
  4. Grant the returned item and save NextState.

The example guarantees Legendary on the tenth roll without one. A natural Legendary resets that pity counter too. Soft pity can be added to the same rule when you want its weight to increase gradually before the guarantee.

An engine can track separate goals with separate counters. For example, LegendaryPity might reset on the Legendary rarity while FeaturedPetPity resets on an item tagged FeaturedPet. A single result can reset both when it matches both rules. Version 1 supports multiple reset counters and soft-pity rules, but only one rule may define hard pity.

Weights, not hard-coded percentages

Every weight is relative. RollEngine normalizes the eligible weights automatically.

With rarity weights of 75, 20, and 5, the initial chances are 75%, 20%, and 5%. Items within a rarity can use completely different ratios. Inside Common, Coins has weight 3 and Potion has weight 1, so a Common result is 75% likely to be Coins and 25% likely to be a Potion. Inside Rare, the Sword and Cape use that same 3-to-1 ratio independently of the rarity weights.

The same rules still work when pity or luck changes those weights; you do not have to manually make them total 100.

Adding a luck boost

Modifiers belong to the request, so a temporary event or player perk does not require another engine.

local DoubleLegendary = {
	Id = "DoubleLegendary",
	Scope = "Rarity",
	Operation = "MultiplyWeight",
	TargetTags = { "LuckAffected" },
	Value = 2,
}

local Response = LootEngine:Roll({
	State = State,
	Random = RandomGenerator,
	Modifiers = { DoubleLegendary },
})

Modifiers can add, multiply, or set weights, or exclude matching rarities and items. They can target IDs or tags, which lets one event modifier affect a whole tagged group without listing every entry.

Value = 2 doubles the raw Legendary weight, not its final percentage. In the 75, 20, 5 pool above, the adjusted weights become 75, 20, 10, so Legendary is 10 / 105, or about 9.52%, not 10%.

Showing the real odds

Use the current state and the same modifiers that the next roll will use:

local OddsResponse = LootEngine:GetOdds({
	State = State,
	Modifiers = { DoubleLegendary },
})

if OddsResponse.Success then
	for _, Rarity in OddsResponse.Result.Rarities do
		print(Rarity.Id, Rarity.Probability * 100 .. "%")
	end
end

This is useful for an odds menu because it includes current pity, modifiers, exclusions, and normalization instead of maintaining a second approximation of the roll math. The result is exact for the supplied state and modifiers on the next roll, not a long-run observed rate.

Bonus tools: verify your roll values

The repository includes two optional development tools under RollEngine.Tools:

  • EmpiricalTesting calls the real Engine:Roll() path repeatedly and returns one table containing every observed rarity rate, item rate, hard-pity count, and the final simulated state.
  • AutoTune uses those empirical runs to search for a MultiplyWeight modifier that approaches a target long-run rate.
local RollEngineModule = script.RollEngine
local EmpiricalTesting = require(RollEngineModule.Tools.EmpiricalTesting)

local Response = EmpiricalTesting.Run({
	Engine = LootEngine,
	State = State,
	Random = Random.new(12345),
	Rolls = 100000,
	CarryState = true,
})

if Response.Success then
	local ObservedRates = {
		Rarities = Response.Result.RarityRates,
		Items = Response.Result.ItemRates,
		HardPityCounts = Response.Result.HardPityCounts,
	}
	-- Format ObservedRates into one log block or display it in your own tooling.
end

CarryState = true validates the full sequential behavior, including soft pity, hard pity, natural resets, and modifiers. CarryState = false repeatedly samples one exact state. A fixed seed makes comparisons repeatable.

These tools answer different questions:

  • GetOdds() gives the exact probability distribution for this one next roll.
  • EmpiricalTesting gives approximate observed rates across a sequence of real, stateful rolls.

AutoTune is deliberately development-only and approximate. Use it to suggest a value, then verify that value with a larger independent sample.

The entire core API is four operations

  • RollEngine.new(Rules) creates a reusable engine from a rules table.
  • Engine:GetDefaultState() creates empty counters for those rules.
  • Engine:Roll(Request) returns a result and the next caller-owned state.
  • Engine:GetOdds(Request) returns the exact current rarity and item probabilities.

Roll() and GetOdds() return { Success = true, Result = ... } or { Success = false, Error = ... }, so request errors can be handled without parsing thrown error text. Invalid static rules are rejected when RollEngine.new() creates the engine.

Connecting it to a real game

A normal server flow is:

Client requests a roll
        ↓
Server validates the request and cost
        ↓
Server loads the player's saved RollEngine state
        ↓
Server calls Roll()
        ↓
Server commits the cost, reward, and NextState together
        ↓
Server sends the result to the client for presentation

Keep the engine and the authoritative roll on the server. Do not let the client choose its own state, modifiers, random source, or reward. If a roll is monetized, also follow Roblox’s current paid-random-item rules and show players the applicable odds.

Important expectations

RollEngine uses raw weights, not fixed percentages. Roll() never mutates the supplied state, so your game must save and reuse NextState. Modifiers are applied before normalization, and natural qualifying results reset pity. GetOdds() reports the exact next-roll distribution for the supplied state and modifiers.

If you need one random system for several parts of a game, and want pity, luck, odds, and testing to agree, RollEngine provides the shared foundation without taking over the rest of your architecture.

8 Likes

Wow, well done.

This is generally an amazing tool!