agents/openai.yaml
interface:
display_name: "Roblox Simulator Economy"
short_description: "Balance and secure Roblox simulator economies"
default_prompt: "Use $roblox-simulator-economy to balance and implement this Roblox simulator economy."
references/formulas.md
# Economy formulas and a worked example
Read this when you need the derivation behind a number in `SKILL.md`, want to solve for a
parameter instead of trying values, or need to explain the calculator's output. Every formula here
is implemented in `scripts/economy_math.luau` and checked by `scripts/test_economy_math.luau`.
## Geometric upgrade prices
Owning `n` levels, the next one costs `c(n) = floor(b0 * g^n)`.
Ignoring the floor, going from `a` to `b` levels costs
S(a, b) = sum over n = a .. b-1 of b0 * g^n = b0 * g^a * (g^(b-a) - 1) / (g - 1) (g > 1)
and `b0 * (b - a)` when `g = 1`. The floored total that players pay is lower by less than one coin
per level (`cumulativeCost` vs `cumulativeCostExact`).
**Buy max without the floor:** the largest `k` with `S(a, a+k) <= M` is
k = floor( log(1 + M * (g - 1) / (b0 * g^a)) / log(g) )
With floored prices this can be off by one, so `maxAffordable` walks the real prices instead and
stops at the upgrade's `maxLevel`. The cap matters: with `g = 1` and a very large balance, an
uncapped loop runs once per affordable level.
**How fast prices grow:** the price doubles every `log(2) / log(g)` levels — about every 5 levels
at `g = 1.15`, every 10 at `g = 1.07`, every 2 at `g = 1.5`. Level 50 at `g = 1.5` costs
`1.5^50`, about 637 million times level 0.
## Rebirth pacing
With requirement `R_k = R0 * r^k`, an additive multiplier `m_k = 1 + k*p`, and a fixed income `I`
after the upgrades are rebought (everything else resets), the wait for rebirth `k+1` is
`T_k = R_k / (I * m_k)`. The ratio between consecutive waits is
T_{k+1} / T_k = r * (1 + k*p) / (1 + (k+1)*p)
It starts at `r / (1 + p)` and climbs towards `r`. Two consequences:
- With an additive multiplier, `r` alone sets the long-run ratio. To keep later rebirths about
x1.6 longer than the previous one, `r` must be near 1.6, whatever `p` is.
- A multiplicative multiplier `m_k = q^k` gives a constant ratio `r / q`. It is easy to tune but
the multiplier itself grows exponentially, so every other income source must be designed for it.
The test `rebirth wait ratio matches ...` checks this formula against the functions.
## Worked example (the calculator's defaults)
`baseIncome 1; upgrade baseCost 10, costGrowth 1.15, +1 income/s per level, 40 levels;`
`rebirth baseRequirement 1e6, requirementGrowth 3, +0.5 multiplier per rebirth`
Output of `lune run scripts/balance.luau` (Lune 0.11.0):
- Reaching level 40 takes 9m 49s; the last levels take about a minute each.
- Rebirth 1 at 41 income/s takes 6h 46m; rebirth 8 takes 137 days. The `vs prev` column reads
x2.00, x2.25, x2.40 ... x2.67 — the formula above with `r = 3`, `p = 0.5`.
- With `requirementGrowth` 1.8 (same `p`), the column reads x1.20 rising to x1.60 and rebirth 8
takes 3d 20h.
- Keeping `r = 3` with a multiplicative multiplier `q = 2.4` gives a constant `3 / 2.4 = x1.25`.
## Offline earnings
paid = floor( min(away, maxHours * 3600) * rate * efficiency ) away <= 0 or NaN -> 0
`away` is the server's `os.time()` now minus the server's `os.time()` saved when the player left.
Clamp before multiplying, so a corrupted or very old timestamp cannot produce an enormous payout.
Efficiency below 1 and a cap keep active play worth more than idling.
## Odds for weighted random outcomes
For outcomes with positive weights `w_i` and total `W`, the probability of outcome `i` is
`w_i / W`, so the percentages `100 * w_i / W` sum to 100 (up to floating-point rounding).
`oddsPercent` returns those percentages and `pickWeighted(outcomes, u)` maps a uniform `u` in
`[0, 1]` to an outcome by walking the cumulative weights in list order (`u = 1`, which Roblox's
`Random:NextNumber()` can return, maps to the last outcome). Because both read the same
list, the displayed odds and the server's roll cannot drift apart. Apply luck boosts or pity by
producing a new weight list, then feed that one list to both functions.
## Number precision
Luau numbers are IEEE-754 doubles. Every integer up to `2^53` (about `9.007e15`) is exact; above
it, adding a small income to a large balance can round away entirely. If balances can exceed
roughly `1e15`, store them as a mantissa/exponent pair (or a base-10 logarithm) and compare in that
form, rather than as plain numbers.
references/server-patterns.md
# Server patterns for a Roblox simulator economy
Read this when you implement the economy in a place: the shared config, the income tick, buy max,
rebirth, offline earnings on join, repairing loaded data, and rolling paid random items. Persistence
calls (`GetAsync`/`UpdateAsync`, session locking, retries) belong to `roblox-datastores`; remote
rate limits and payload validation in depth belong to `roblox-networking`.
The snippets use only `Players`, `ReplicatedStorage`, `PolicyService`, `RemoteEvent`, `Random`,
`task`, and `os.time()`. They were compiled and run under Lune 0.11.0 against minimal stand-ins
for those services (not shipped with this skill); behaviour inside Studio still needs a Server &
Clients playtest.
## Layout
```text
ReplicatedStorage/
Shared/EconomyMath ModuleScript: scripts/economy_math.luau, unchanged
Shared/EconomyConfig ModuleScript: the numbers (same shape as balance.luau's JSON)
Remotes/BuyUpgrade RemoteEvent (client -> server: upgrade id)
Remotes/BuyMax RemoteEvent (client -> server: upgrade id)
Remotes/Rebirth RemoteEvent (client -> server: no arguments)
Remotes/WalletChanged RemoteEvent (server -> client: wallet snapshot)
ServerScriptService/
Economy.server.luau owns every session table
```
The config sits in `ReplicatedStorage` so the client can show prices and odds from the same table
the server uses. That is safe because the server never reads amounts from the client.
```lua
-- ReplicatedStorage/Shared/EconomyConfig
return {
baseIncome = 1,
upgrades = {
Income = { currency = "Coins", baseCost = 10, costGrowth = 1.15, incomePerLevel = 1, maxLevel = 100 },
},
rebirth = { baseRequirement = 1e6, requirementGrowth = 1.8, multiplierPerRebirth = 0.5 },
offline = { maxHours = 8, efficiency = 0.5 },
eggs = {
Basic = {
price = { Coins = 500 },
outcomes = {
{ id = "Cat", weight = 70 },
{ id = "Dog", weight = 25 },
{ id = "Dragon", weight = 5 },
},
},
},
}
```
## Sessions and the income tick
```lua
-- ServerScriptService/Economy.server.luau
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local EconomyMath = require(ReplicatedStorage.Shared.EconomyMath)
local Config = require(ReplicatedStorage.Shared.EconomyConfig)
local Remotes = ReplicatedStorage.Remotes
type Session = {
wallet: { [string]: number },
levels: { [string]: number },
rebirths: number,
carry: number, -- fractional income not yet paid
}
local sessions: { [Player]: Session } = {}
local function incomePerSecond(session: Session): number
local upgrade = Config.upgrades.Income
local base = Config.baseIncome + (session.levels.Income or 0) * upgrade.incomePerLevel
return base * EconomyMath.rebirthMultiplier(Config.rebirth.multiplierPerRebirth, session.rebirths)
end
local function pushWallet(player: Player, session: Session)
Remotes.WalletChanged:FireClient(player, table.clone(session.wallet))
end
task.spawn(function()
while true do
local elapsed = task.wait(1)
for player, session in sessions do
session.carry += incomePerSecond(session) * elapsed
local whole = math.floor(session.carry)
session.carry -= whole
session.wallet.Coins = (session.wallet.Coins or 0) + whole
pushWallet(player, session)
end
end
end)
```
`task.wait` returns the time that actually passed, so a slow frame does not lose income, and the
`carry` field keeps fractional multipliers (x1.5 on 41/s) from being floored away every second.
## Buy one and buy max
```lua
Remotes.BuyUpgrade.OnServerEvent:Connect(function(player: Player, upgradeId: unknown)
local session = sessions[player]
if not session or type(upgradeId) ~= "string" then return end
local upgrade = Config.upgrades[upgradeId]
if not upgrade then return end
local owned = session.levels[upgradeId] or 0
if owned >= upgrade.maxLevel then return end
local price = EconomyMath.levelCost(upgrade.baseCost, upgrade.costGrowth, owned)
if EconomyMath.trySpend(session.wallet, { [upgrade.currency] = price }) then
session.levels[upgradeId] = owned + 1
pushWallet(player, session)
end
end)
Remotes.BuyMax.OnServerEvent:Connect(function(player: Player, upgradeId: unknown)
local session = sessions[player]
if not session or type(upgradeId) ~= "string" then return end
local upgrade = Config.upgrades[upgradeId]
if not upgrade then return end
local owned = session.levels[upgradeId] or 0
local money = session.wallet[upgrade.currency] or 0
local count, total = EconomyMath.maxAffordable(upgrade.baseCost, upgrade.costGrowth, owned, money, upgrade.maxLevel)
if count > 0 and EconomyMath.trySpend(session.wallet, { [upgrade.currency] = total }) then
session.levels[upgradeId] = owned + count
pushWallet(player, session)
end
end)
```
Neither handler yields between the balance check and the spend, so two requests from the same
player cannot both pass the check. Put a per-player token bucket (see `roblox-networking`) in front
of both before shipping.
## Rebirth
```lua
Remotes.Rebirth.OnServerEvent:Connect(function(player: Player)
local session = sessions[player]
if not session then return end
local rb = Config.rebirth
local requirement = EconomyMath.rebirthRequirement(rb.baseRequirement, rb.requirementGrowth, session.rebirths)
if (session.wallet.Coins or 0) < requirement then return end
session.wallet.Coins = 0
table.clear(session.levels)
session.carry = 0
session.rebirths += 1
pushWallet(player, session)
end)
```
## Loading: repair first, then pay offline income
`saved` is whatever `roblox-datastores` loaded (or `nil` for a new player). Repairing never errors
and reports what it changed, so you can log tampering or old-version data.
```lua
local function repair(saved: any, now: number): (Session, { string })
local notes = {}
local data = if type(saved) == "table" then saved else {}
local session: Session = { wallet = {}, levels = {}, rebirths = 0, carry = 0 }
local wallet = if type(data.wallet) == "table" then data.wallet else {}
for currency, amount in wallet do
local fixed = EconomyMath.sanitizeAmount(amount)
if fixed ~= amount then table.insert(notes, `wallet.{currency}`) end
if type(currency) == "string" then session.wallet[currency] = fixed end
end
local levels = if type(data.levels) == "table" then data.levels else {}
for id, level in levels do
local upgrade = Config.upgrades[id]
if upgrade then
local fixed = EconomyMath.sanitizeLevel(level, upgrade.maxLevel)
if fixed ~= level then table.insert(notes, `levels.{id}`) end
session.levels[id] = fixed
else
table.insert(notes, `dropped unknown upgrade {tostring(id)}`)
end
end
session.rebirths = EconomyMath.sanitizeAmount(data.rebirths)
-- Offline income from the server clock only. A missing, non-number or future
-- timestamp counts as "now", which pays nothing.
local lastSeen = data.lastSeen
if type(lastSeen) ~= "number" or not (lastSeen <= now) then
lastSeen = now
end
local off = Config.offline
local paid = EconomyMath.offlineIncome(now - lastSeen, incomePerSecond(session), off.maxHours, off.efficiency)
session.wallet.Coins = (session.wallet.Coins or 0) + paid
return session, notes
end
```
On leave, save `lastSeen = os.time()` from the server along with the wallet, levels and rebirth
count, then clear `sessions[player]` in `Players.PlayerRemoving`. Never accept a timestamp from the
client.
## Paid random items (eggs, crates, spins)
The rules come from Roblox's
[paid random items guidelines](https://create.roblox.com/docs/production/monetization/paid-random-items);
this section only shows how to keep the shown odds and the roll identical.
```lua
local PolicyService = game:GetService("PolicyService")
local rng = Random.new()
local function paidRandomItemsRestricted(player: Player): boolean
local ok, policy = pcall(PolicyService.GetPolicyInfoForPlayerAsync, PolicyService, player)
-- If the policy lookup fails, treat the player as restricted.
return not ok or policy.ArePaidRandomItemsRestricted
end
local function openEgg(player: Player, eggId: string): string?
local session = sessions[player]
local egg = Config.eggs[eggId]
if not session or not egg then return nil end
-- Only needed when the price currency can be bought with Robux.
if paidRandomItemsRestricted(player) then return nil end
if sessions[player] ~= session then return nil end -- player left during the lookup
if not EconomyMath.trySpend(session.wallet, egg.price) then return nil end
local outcomes = egg.outcomes -- apply luck boosts here and show this same list
return EconomyMath.pickWeighted(outcomes, rng:NextNumber())
end
```
The client builds the odds panel from `EconomyMath.oddsPercent(Config.eggs[eggId].outcomes)` (or
the boosted list the server sends while a boost is active), so the panel shows exactly what the
server rolls. `GetPolicyInfoForPlayerAsync` yields, so it runs before the spend, and the session
is checked again afterwards. For restricted players, the guidelines list the allowed treatments
(for example a free earnable path, a fixed non-random order shown before purchase, or hiding or
blocking the item); pick one deliberately.
scripts/balance.luau
-- Pacing calculator for a simulator / tycoon / idle economy (Lune 0.11.0).
-- Read-only: it reads one optional JSON file and prints to stdout.
local fs = require("@lune/fs")
local process = require("@lune/process")
local serde = require("@lune/serde")
local stdio = require("@lune/stdio")
local EconomyMath = require("./economy_math")
local USAGE = [[
Usage: lune run scripts/balance.luau [CONFIG.json]
lune run scripts/balance.luau --print-defaults
lune run scripts/balance.luau --help
Prints, for one player who spends everything on a single upgrade:
- price, total spent, income and wait for each upgrade level;
- requirement, multiplier, wait and growth ratio ("vs prev") for each rebirth;
- the most one offline session can pay.
With no CONFIG it uses built-in example numbers. --print-defaults writes those
numbers as JSON, ready to copy into your own CONFIG file.
CONFIG fields (all numbers):
baseIncome income per second before upgrades
upgrade.baseCost (>= 1) price of the first level
upgrade.costGrowth (>= 1) price multiplier per level owned
upgrade.incomePerLevel (>= 0) income per second added by each level
upgrade.levels (whole, 1..1000) levels to simulate
rebirth.baseRequirement (> 0) currency needed for the first rebirth
rebirth.requirementGrowth (>= 1) requirement multiplier per rebirth
rebirth.multiplierPerRebirth (>= 0) additive income multiplier per rebirth
rebirth.count (whole, 1..100) rebirths to simulate
offline.maxHours (>= 0) offline earning cap in hours
offline.efficiency (0..1) share of online income paid offline
Exit status: 0 on success, 1 on a missing file, invalid JSON or invalid config.]]
local DEFAULT = {
baseIncome = 1,
upgrade = { baseCost = 10, costGrowth = 1.15, incomePerLevel = 1, levels = 40 },
rebirth = { baseRequirement = 1e6, requirementGrowth = 3, multiplierPerRebirth = 0.5, count = 8 },
offline = { maxHours = 8, efficiency = 0.5 },
}
local function fail(message: string): never
stdio.ewrite("balance.luau: " .. message .. "\n")
stdio.ewrite("Run with --help for usage.\n")
return process.exit(1)
end
-- Each rule: section (nil = top level), field, minimum, maximum, whole number?
local RULES: { { any } } = {
{ nil, "baseIncome", 0, math.huge, false },
{ "upgrade", "baseCost", 1, math.huge, false },
{ "upgrade", "costGrowth", 1, math.huge, false },
{ "upgrade", "incomePerLevel", 0, math.huge, false },
{ "upgrade", "levels", 1, 1000, true },
{ "rebirth", "baseRequirement", 1e-9, math.huge, false },
{ "rebirth", "requirementGrowth", 1, math.huge, false },
{ "rebirth", "multiplierPerRebirth", 0, math.huge, false },
{ "rebirth", "count", 1, 100, true },
{ "offline", "maxHours", 0, math.huge, false },
{ "offline", "efficiency", 0, 1, false },
}
local function validate(config: any)
if type(config) ~= "table" or #config > 0 then
fail("config must be a JSON object")
end
for _, rule in RULES do
local section, field, low, high, whole = rule[1], rule[2], rule[3], rule[4], rule[5]
local container = config
local path = field
if section then
container = config[section]
path = section .. "." .. field
if type(container) ~= "table" then
fail(`missing object "{section}"`)
end
end
local value = container[field]
if type(value) ~= "number" or value ~= value or value == math.huge then
fail(`"{path}" must be a finite number`)
end
if value < low or value > high then
fail(`"{path}" is {value}; allowed range is {low} to {high}`)
end
if whole and value % 1 ~= 0 then
fail(`"{path}" must be a whole number`)
end
end
end
local args = process.args
if #args > 1 then
fail("expected at most one argument")
end
local config: any = DEFAULT
local first = args[1]
if first == "--help" or first == "-h" then
print(USAGE)
process.exit(0)
elseif first == "--print-defaults" then
print(serde.encode("json", DEFAULT, true))
process.exit(0)
elseif first and string.sub(first, 1, 1) == "-" then
fail(`unknown option "{first}"`)
elseif first then
if not fs.isFile(first) then
fail(`config file not found (or not a regular file): {first}`)
end
local readOk, text = pcall(fs.readFile, first)
if not readOk then
fail(`cannot read {first}: {string.match(tostring(text), "^[^\n]*")}`)
end
local decodeOk, decoded = pcall(serde.decode, "json", text)
if not decodeOk then
-- Keep only the first line: the decoder's message carries a stack trace.
local reason = string.match(tostring(decoded), "^[^\n]*")
fail(`{first} is not valid JSON: {reason}`)
end
config = decoded
end
validate(config)
local function clock(seconds: number): string
if seconds == math.huge then
return "never"
elseif seconds < 60 then
return string.format("%ds", seconds)
elseif seconds < 3600 then
return string.format("%dm %02ds", seconds // 60, seconds % 60)
elseif seconds < 86400 then
return string.format("%dh %02dm", seconds // 3600, (seconds % 3600) // 60)
end
return string.format("%dd %dh", seconds // 86400, (seconds % 86400) // 3600)
end
local show = EconomyMath.formatNumber
local up = config.upgrade
print("Upgrade pacing (no rebirths, buying one level at a time)")
print(string.format("%5s %14s %14s %12s %12s", "level", "price", "total spent", "income/s", "wait"))
local totalTime = 0
for level = 0, up.levels - 1 do
local income = config.baseIncome + level * up.incomePerLevel
local price = EconomyMath.levelCost(up.baseCost, up.costGrowth, level)
local wait = EconomyMath.secondsToEarn(price, income)
totalTime += wait
if level < 10 or level % 5 == 4 or level == up.levels - 1 then
print(
string.format(
"%5d %14s %14s %12s %12s",
level + 1,
show(price),
show(EconomyMath.cumulativeCostExact(up.baseCost, up.costGrowth, 0, level + 1)),
show(income),
clock(wait)
)
)
end
end
print(string.format("Time to level %d: %s", up.levels, clock(totalTime)))
local rb = config.rebirth
local income = config.baseIncome + up.levels * up.incomePerLevel
print(string.format("\nRebirth pacing (income %s/s before multipliers)", show(income)))
print(string.format("%7s %16s %11s %12s %8s", "rebirth", "requirement", "multiplier", "wait", "vs prev"))
local previous: number? = nil
for done = 0, rb.count - 1 do
local requirement = EconomyMath.rebirthRequirement(rb.baseRequirement, rb.requirementGrowth, done)
local multiplier = EconomyMath.rebirthMultiplier(rb.multiplierPerRebirth, done)
local wait = EconomyMath.secondsToEarn(requirement, income * multiplier)
local ratio = "-"
if previous and previous > 0 and previous ~= math.huge then
ratio = string.format("x%.2f", wait / previous)
end
print(
string.format(
"%7d %16s %11s %12s %8s",
done + 1,
show(requirement),
string.format("x%.1f", multiplier),
clock(wait),
ratio
)
)
previous = wait
end
local off = config.offline
print(
string.format(
"\nOffline: %g h cap at %d%% efficiency pays up to %s at %s/s",
off.maxHours,
off.efficiency * 100,
show(EconomyMath.offlineIncome(off.maxHours * 3600, income, off.maxHours, off.efficiency)),
show(income)
)
)
scripts/economy_math.luau
--!strict
-- Pure Luau maths for simulator, tycoon, clicker and idle economies.
-- No engine services: it runs under Lune 0.11.0 (balance.luau and
-- test_economy_math.luau use it) and inside a Roblox ModuleScript unchanged.
local EconomyMath = {}
-- Display suffixes, one per factor of 1000. Index 1 is 1e3 (K); index 21 is 1e63 (Vg).
EconomyMath.SUFFIXES = {
"K", "M", "B", "T", "Qa", "Qi", "Sx", "Sp", "Oc", "No", "Dc",
"Ud", "Dd", "Td", "Qad", "Qid", "Sxd", "Spd", "Ocd", "Nod", "Vg",
}
local function isFinite(value: number): boolean
return value == value and value ~= math.huge and value ~= -math.huge
end
-- Price of the next level when `level` levels are owned: floor(base * growth ^ level).
function EconomyMath.levelCost(base: number, growth: number, level: number): number
return math.floor(base * growth ^ level)
end
-- Total cost of going from `from` to `to` levels (geometric series, before flooring
-- each step). Use cumulativeCostExact when the floored sum matters.
function EconomyMath.cumulativeCost(base: number, growth: number, from: number, to: number): number
if to <= from then
return 0
end
if growth == 1 then
return base * (to - from)
end
return base * growth ^ from * (growth ^ (to - from) - 1) / (growth - 1)
end
-- Sum of the floored per-level prices from `from` to `to`: what players actually pay.
function EconomyMath.cumulativeCostExact(base: number, growth: number, from: number, to: number): number
local total = 0
for level = from, to - 1 do
total += EconomyMath.levelCost(base, growth, level)
end
return total
end
-- "Buy max": how many levels `money` buys starting from `owned`, never going past
-- `maxLevel`. Returns the count and the total price. Errors on a price below 1 or a
-- non-finite amount, either of which would otherwise loop forever.
function EconomyMath.maxAffordable(
base: number,
growth: number,
owned: number,
money: number,
maxLevel: number
): (number, number)
if not isFinite(money) or not isFinite(maxLevel) then
error("maxAffordable: money and maxLevel must be finite numbers", 2)
end
local count, spent = 0, 0
while owned + count < maxLevel do
local price = EconomyMath.levelCost(base, growth, owned + count)
if price < 1 then
error("maxAffordable: every price must be at least 1 (raise baseCost)", 2)
end
if spent + price > money then
break
end
spent += price
count += 1
end
return count, spent
end
-- Requirement for the next rebirth after `done` rebirths: floor(base * growth ^ done).
function EconomyMath.rebirthRequirement(base: number, growth: number, done: number): number
return math.floor(base * growth ^ done)
end
-- Additive income multiplier after `done` rebirths: 1 + done * perRebirth.
function EconomyMath.rebirthMultiplier(perRebirth: number, done: number): number
return 1 + done * perRebirth
end
-- Seconds needed to earn `amount` at `perSecond`; math.huge when there is no income.
function EconomyMath.secondsToEarn(amount: number, perSecond: number): number
if not (perSecond > 0) then
return math.huge
end
return amount / perSecond
end
-- Offline income: seconds away (server clock), capped at maxHours, scaled by
-- efficiency, floored. Zero, negative or NaN time away pays nothing.
function EconomyMath.offlineIncome(secondsAway: number, perSecond: number, maxHours: number, efficiency: number): number
if not (secondsAway > 0) then
return 0
end
local counted = math.min(secondsAway, maxHours * 3600)
return math.floor(counted * perSecond * efficiency)
end
-- Spends several currencies at once, or none. `wallet` is changed only when every
-- cost is a finite, non-negative number the wallet covers.
function EconomyMath.trySpend(wallet: { [string]: number }, costs: { [string]: number }): boolean
for currency, cost in costs do
if type(cost) ~= "number" or not isFinite(cost) or cost < 0 then
return false
end
if (wallet[currency] or 0) < cost then
return false
end
end
for currency, cost in costs do
wallet[currency] = (wallet[currency] or 0) - cost
end
return true
end
-- Repairs one stored currency amount: anything that is not a finite, non-negative
-- number becomes 0, and fractions are floored. Never errors.
function EconomyMath.sanitizeAmount(value: unknown): number
if type(value) ~= "number" or not isFinite(value) or value < 0 then
return 0
end
return math.floor(value)
end
-- Repairs one stored upgrade level: a whole number from 0 to maxLevel.
function EconomyMath.sanitizeLevel(value: unknown, maxLevel: number): number
return math.min(EconomyMath.sanitizeAmount(value), maxLevel)
end
export type WeightedOutcome = { id: string, weight: number }
local function totalWeight(outcomes: { WeightedOutcome }): number
if #outcomes == 0 then
error("weighted outcomes: the list is empty", 3)
end
local total = 0
for _, outcome in outcomes do
if type(outcome.weight) ~= "number" or not isFinite(outcome.weight) or outcome.weight <= 0 then
error(`weighted outcomes: "{outcome.id}" needs a finite weight above 0`, 3)
end
total += outcome.weight
end
return total
end
-- Percent chance of every outcome, keyed by id. Use the same list the server rolls
-- with (after applying any luck boost or pity) so the shown odds cannot drift.
function EconomyMath.oddsPercent(outcomes: { WeightedOutcome }): { [string]: number }
local total = totalWeight(outcomes)
local percents: { [string]: number } = {}
for _, outcome in outcomes do
percents[outcome.id] = outcome.weight / total * 100
end
return percents
end
-- Picks an outcome id from `unit`, a uniform random number in [0, 1], walking the
-- weights in list order. Roblox's Random:NextNumber() is documented as inclusive of 1;
-- a unit of 1 (or more) returns the last outcome.
function EconomyMath.pickWeighted(outcomes: { WeightedOutcome }, unit: number): string
local target = unit * totalWeight(outcomes)
local running = 0
for _, outcome in outcomes do
running += outcome.weight
if target < running then
return outcome.id
end
end
return outcomes[#outcomes].id
end
-- Formats a number for display: 999 -> "999", 1500 -> "1.5K", 999950 -> "1.0M"
-- (rounds before choosing the suffix, so it never prints "1000.0K"). Values past the
-- last suffix use scientific notation. Display only; never parse the result back.
function EconomyMath.formatNumber(value: number, decimals: number?): string
local places = decimals or 1
if value ~= value then
return "nan"
elseif value == math.huge then
return "inf"
elseif value == -math.huge then
return "-inf"
end
local sign = if value < 0 then "-" else ""
local scaled = math.abs(value)
if scaled < 1000 then
return sign .. string.format("%d", math.floor(scaled))
end
local tier = 0
while scaled >= 1000 and tier < #EconomyMath.SUFFIXES do
scaled /= 1000
tier += 1
end
if scaled >= 1000 then
return sign .. string.format("%.2e", math.abs(value))
end
local factor = 10 ^ places
local rounded = math.floor(scaled * factor + 0.5) / factor
if rounded >= 1000 then
if tier == #EconomyMath.SUFFIXES then
return sign .. string.format("%.2e", math.abs(value))
end
rounded /= 1000
tier += 1
end
return sign .. string.format("%." .. places .. "f", rounded) .. EconomyMath.SUFFIXES[tier]
end
return EconomyMath
scripts/test_economy_math.luau
-- Tests for economy_math.luau (Lune 0.11.0).
-- From the skill folder: lune run scripts/test_economy_math.luau
-- Exits 0 when every test passes, 1 otherwise.
local process = require("@lune/process")
local EconomyMath = require("./economy_math")
if process.args[1] == "--help" or process.args[1] == "-h" then
print("Usage: lune run scripts/test_economy_math.luau\nRuns the economy_math tests; exit 1 on any failure.")
process.exit(0)
end
local passed, failed = 0, 0
local function test(name: string, fn: () -> ())
local ok, err = pcall(fn)
if ok then
passed += 1
else
failed += 1
print(`FAIL {name}\n {err}`)
end
end
local function eq(actual: any, expected: any, label: string?)
if actual ~= expected then
error(`{label or "value"}: expected {tostring(expected)}, got {tostring(actual)}`, 2)
end
end
local function near(actual: number, expected: number, tolerance: number, label: string)
if math.abs(actual - expected) > tolerance then
error(`{label}: expected ~{expected}, got {actual}`, 2)
end
end
test("levelCost floors base * growth ^ level", function()
eq(EconomyMath.levelCost(10, 1.15, 0), 10, "level 0")
eq(EconomyMath.levelCost(10, 1.15, 1), 11, "level 1 (11.5 floored)")
eq(EconomyMath.levelCost(100, 2, 10), 102400, "doubling")
end)
test("cumulativeCost matches the summed series", function()
local loop = 0
for level = 3, 19 do
loop += 50 * 1.2 ^ level
end
near(EconomyMath.cumulativeCost(50, 1.2, 3, 20), loop, 1e-6, "geometric sum")
eq(EconomyMath.cumulativeCost(50, 1, 3, 20), 50 * 17, "flat growth")
eq(EconomyMath.cumulativeCost(50, 1.2, 5, 5), 0, "empty range")
end)
test("cumulativeCostExact sums floored prices and stays under the smooth sum", function()
local exact = EconomyMath.cumulativeCostExact(10, 1.15, 0, 30)
local smooth = EconomyMath.cumulativeCost(10, 1.15, 0, 30)
assert(exact <= smooth, "floored sum is at most the smooth sum")
assert(smooth - exact < 30, "and loses less than 1 per level")
end)
test("maxAffordable buys exactly as many levels as the money covers", function()
local owned, money = 4, 1000
local count, spent = EconomyMath.maxAffordable(10, 1.15, owned, money, 100)
eq(spent, EconomyMath.cumulativeCostExact(10, 1.15, owned, owned + count), "reported total")
assert(spent <= money, "affordable")
assert(spent + EconomyMath.levelCost(10, 1.15, owned + count) > money, "one more is not")
eq((EconomyMath.maxAffordable(10, 1.15, 0, 9, 100)), 0, "can't afford the first level")
end)
test("maxAffordable stops at maxLevel and rejects inputs that would never end", function()
eq((EconomyMath.maxAffordable(10, 1, 0, 1e12, 25)), 25, "capped at maxLevel")
eq((EconomyMath.maxAffordable(10, 1, 30, 1e12, 25)), 0, "already past the cap")
assert(not pcall(EconomyMath.maxAffordable, 0.5, 1.15, 0, 100, 10), "price 0 errors")
assert(not pcall(EconomyMath.maxAffordable, 10, 1.15, 0, math.huge, 10), "infinite money errors")
assert(not pcall(EconomyMath.maxAffordable, 10, 1.15, 0, 0 / 0, 10), "NaN money errors")
end)
test("rebirth requirement and multiplier", function()
eq(EconomyMath.rebirthRequirement(1e6, 3, 0), 1e6, "first")
eq(EconomyMath.rebirthRequirement(1e6, 3, 2), 9e6, "third")
eq(EconomyMath.rebirthMultiplier(0.5, 0), 1, "no rebirths")
eq(EconomyMath.rebirthMultiplier(0.5, 3), 2.5, "three rebirths")
end)
test("rebirth wait ratio matches r * (1 + k*p) / (1 + (k+1)*p)", function()
local r, p, income = 3, 0.5, 41
for k = 0, 6 do
local now = EconomyMath.secondsToEarn(
EconomyMath.rebirthRequirement(1e6, r, k),
income * EconomyMath.rebirthMultiplier(p, k)
)
local nextWait = EconomyMath.secondsToEarn(
EconomyMath.rebirthRequirement(1e6, r, k + 1),
income * EconomyMath.rebirthMultiplier(p, k + 1)
)
near(nextWait / now, r * (1 + k * p) / (1 + (k + 1) * p), 1e-9, `ratio at k={k}`)
end
end)
test("secondsToEarn handles zero and NaN income", function()
eq(EconomyMath.secondsToEarn(100, 4), 25, "simple")
eq(EconomyMath.secondsToEarn(100, 0), math.huge, "no income")
eq(EconomyMath.secondsToEarn(100, 0 / 0), math.huge, "NaN income")
end)
test("offlineIncome caps, scales and ignores backwards or NaN clocks", function()
eq(EconomyMath.offlineIncome(3600, 10, 8, 0.5), 18000, "one hour at half rate")
eq(EconomyMath.offlineIncome(48 * 3600, 10, 8, 1), 8 * 3600 * 10, "capped at 8 hours")
eq(EconomyMath.offlineIncome(-500, 10, 8, 1), 0, "clock went backwards")
eq(EconomyMath.offlineIncome(0 / 0, 10, 8, 1), 0, "NaN time away")
end)
test("trySpend is all-or-nothing", function()
local wallet = { Coins = 100, Gems = 5 }
eq(EconomyMath.trySpend(wallet, { Coins = 60, Gems = 10 }), false, "gems short")
eq(wallet.Coins, 100, "coins untouched after failed spend")
eq(EconomyMath.trySpend(wallet, { Coins = 60, Gems = 5 }), true, "affordable")
eq(wallet.Coins, 40, "coins spent")
eq(wallet.Gems, 0, "gems spent")
eq(EconomyMath.trySpend(wallet, { Coins = -10 }), false, "negative cost rejected")
eq(EconomyMath.trySpend(wallet, { Coins = 0 / 0 }), false, "NaN cost rejected")
eq(EconomyMath.trySpend(wallet, { Tickets = 1 }), false, "missing currency counts as 0")
eq(wallet.Coins, 40, "coins untouched after rejected spends")
end)
test("sanitizeAmount and sanitizeLevel repair tampered or old save values", function()
eq(EconomyMath.sanitizeAmount(250.9), 250, "fraction floored")
eq(EconomyMath.sanitizeAmount(-5), 0, "negative")
eq(EconomyMath.sanitizeAmount(0 / 0), 0, "NaN")
eq(EconomyMath.sanitizeAmount(math.huge), 0, "inf")
eq(EconomyMath.sanitizeAmount("999"), 0, "string")
eq(EconomyMath.sanitizeAmount(nil), 0, "missing")
eq(EconomyMath.sanitizeLevel(80, 50), 50, "clamped to max level")
eq(EconomyMath.sanitizeLevel(12, 50), 12, "valid level kept")
end)
test("formatNumber rounds before picking a suffix", function()
eq(EconomyMath.formatNumber(0), "0", "zero")
eq(EconomyMath.formatNumber(999), "999", "below 1000")
eq(EconomyMath.formatNumber(1500), "1.5K", "thousands")
eq(EconomyMath.formatNumber(999950), "1.0M", "rollover: not 1000.0K")
eq(EconomyMath.formatNumber(999949), "999.9K", "just below rollover")
eq(EconomyMath.formatNumber(3.4e6), "3.4M", "millions")
eq(EconomyMath.formatNumber(2e15), "2.0Qa", "quadrillions")
eq(EconomyMath.formatNumber(-1500), "-1.5K", "negative")
eq(EconomyMath.formatNumber(1234567, 2), "1.23M", "two decimals")
eq(EconomyMath.formatNumber(1e63), "1.0Vg", "last suffix")
eq(EconomyMath.formatNumber(1e66), "1.00e+66", "past the last suffix")
eq(EconomyMath.formatNumber(0 / 0), "nan", "NaN")
eq(EconomyMath.formatNumber(math.huge), "inf", "inf")
end)
test("oddsPercent and pickWeighted read the same weights", function()
local outcomes = {
{ id = "Common", weight = 70 },
{ id = "Rare", weight = 25 },
{ id = "Legendary", weight = 5 },
}
local odds = EconomyMath.oddsPercent(outcomes)
eq(odds.Common, 70, "common percent")
eq(odds.Legendary, 5, "legendary percent")
near(odds.Common + odds.Rare + odds.Legendary, 100, 1e-9, "sum")
local thirds = EconomyMath.oddsPercent({ { id = "A", weight = 1 }, { id = "B", weight = 1 }, { id = "C", weight = 1 } })
near(thirds.A + thirds.B + thirds.C, 100, 1e-9, "sum of thirds")
eq(EconomyMath.pickWeighted(outcomes, 0), "Common", "lowest roll")
eq(EconomyMath.pickWeighted(outcomes, 0.6999), "Common", "just under the first boundary")
eq(EconomyMath.pickWeighted(outcomes, 0.7), "Rare", "first boundary")
eq(EconomyMath.pickWeighted(outcomes, 0.95), "Legendary", "second boundary")
eq(EconomyMath.pickWeighted(outcomes, 0.999999), "Legendary", "highest roll")
eq(EconomyMath.pickWeighted(outcomes, 1), "Legendary", "inclusive upper bound")
assert(not pcall(EconomyMath.oddsPercent, {}), "empty list errors")
assert(not pcall(EconomyMath.oddsPercent, { { id = "Bad", weight = 0 } }), "zero weight errors")
assert(not pcall(EconomyMath.pickWeighted, { { id = "Bad", weight = 0 / 0 } }, 0.5), "NaN weight errors")
end)
print(`\n{passed} passed, {failed} failed`)
process.exit(if failed > 0 then 1 else 0)
SKILL.md
---
name: roblox-simulator-economy
description: >
Design, balance, and implement the economy of a Roblox simulator, tycoon, clicker, or idle game
in Luau: upgrade cost curves, buy max, income per second, rebirth/prestige requirements and
multipliers, offline earnings, big-number display (1.5K, 3.4M, 2.0Qa), server-authoritative
purchases, repairing tampered or old save data, and odds disclosure for paid random items such
as eggs, crates, and spins. Includes a Lune pacing calculator. Use when choosing or checking
baseCost/costGrowth/rebirth numbers, writing upgrade/currency/rebirth/offline code, or when
progress stalls, a rebirth takes far longer than the last, numbers display as 1000K, offline
earnings pay too much, or players gain currency they did not earn.
---
# Roblox simulator economy
Choose the economy's numbers with a pacing table before writing gameplay code, then implement them
so the server owns every amount. Code targets Luau on Roblox's rolling platform APIs; the bundled
scripts are pure Luau and were run with **Lune 0.11.0**, outside Studio.
## When to use
- Picking or reviewing `baseCost`, `costGrowth`, rebirth requirement and multiplier, or offline
cap and efficiency values for a simulator, tycoon, clicker, or idle game.
- Writing upgrade, buy-max, currency, rebirth, or offline-earnings code in Luau.
- Displaying large numbers, or values above about `1e15` that lose precision.
- Adding eggs, crates, or spins bought with Robux, or with currency that can be bought with Robux.
**When not to use:** DataStore reads, writes, session locking and retries → `roblox-datastores`.
Remote protocol design and rate limiting in depth → `roblox-networking`. Shop and HUD layout →
`roblox-ui` + `game-ui-ux`. A wave-defense economy → `tower-defense`. Engine-neutral save format
questions → `save-systems`.
## Workflow
1. **Inspect.** Find where currency, upgrade, and rebirth values live now and which scripts change
them. List every RemoteEvent that touches currency and what each one sends.
2. **Write the numbers as one config table**: base income; each upgrade's `baseCost`,
`costGrowth`, effect, and `maxLevel`; rebirth `baseRequirement`, `requirementGrowth`,
`multiplierPerRebirth`; offline `maxHours`, `efficiency`. Server and client both read it.
3. **Run the pacing calculator** from the skill directory (paths are relative to it):
`lune run scripts/balance.luau --print-defaults > economy.json`, edit the file, then
`lune run scripts/balance.luau economy.json`. Read the `wait` and `vs prev` columns and tune
until they match the targets below. `--help` lists every field and its allowed range; a
missing file, invalid JSON, or out-of-range field prints an error and exits 1.
4. **Implement on the server** with the patterns below and
[references/server-patterns.md](references/server-patterns.md). Copy
`scripts/economy_math.luau` unchanged into a ModuleScript so the maths you tested is the maths
that runs.
5. **Verify.** `lune run scripts/test_economy_math.luau` (exits 1 on failure) for the maths; a
Studio playtest with Server & Clients for purchases, rebirth, leaving and rejoining, and
invalid remote arguments.
## Formulas
| Quantity | Formula | Notes |
|---|---|---|
| Price of the next level | `floor(baseCost * costGrowth ^ owned)` | 1.07-1.15 for upgrades bought many times; 1.5-3 for rare large ones |
| Cost from level a to b | `baseCost * g^a * (g^(b-a) - 1) / (g - 1)` | Players pay the floored per-level sum, slightly less |
| Buy max | walk real prices until the next exceeds the balance or `maxLevel` | Safer than the log formula with floored prices |
| Rebirth requirement k | `baseRequirement * requirementGrowth ^ k` | |
| Rebirth multiplier | `1 + k * multiplierPerRebirth` | Additive grows slowly; multiplicative (`q ^ k`) grows exponentially |
| Offline income | `floor(min(away, maxHours * 3600) * rate * efficiency)` | Away time of 0 or less (clock went back) pays 0 |
Derivations, the rebirth wait-ratio formula, and a worked example:
[references/formulas.md](references/formulas.md).
## Pacing targets (starting points, not rules)
- **Early upgrades take seconds.** In the default config the first 10 levels take 3-10 s each.
- **Each rebirth should take only a little longer than the last**, for example x1.2-x1.5. The
calculator's `vs prev` column is that ratio. With an additive multiplier it approaches
`requirementGrowth` over time, so `requirementGrowth = 3` gives x2.0 rising to x2.7 and rebirth 8
takes 137 days at the default income. `requirementGrowth = 1.8` gives x1.2 rising to x1.6.
- **Offline earns less than online.** An efficiency of 0.25-0.5 and an 8-12 hour cap keep playing
worth more than idling without zeroing out players who log off.
## Server-owned purchases
A RemoteEvent carries what the player wants ("buy upgrade `Income`"), never an amount or a price.
The server looks up the price, checks the balance, spends, and grants without yielding in between.
```lua
-- ServerScriptService/Economy.server.luau (excerpt; full version in references/server-patterns.md)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local EconomyMath = require(ReplicatedStorage.Shared.EconomyMath)
local Config = require(ReplicatedStorage.Shared.EconomyConfig)
ReplicatedStorage.Remotes.BuyUpgrade.OnServerEvent:Connect(function(player: Player, upgradeId: unknown)
local session = sessions[player]
if not session or type(upgradeId) ~= "string" then return end
local upgrade = Config.upgrades[upgradeId]
if not upgrade then return end -- unknown id
local owned = session.levels[upgradeId] or 0
if owned >= upgrade.maxLevel then return end
local price = EconomyMath.levelCost(upgrade.baseCost, upgrade.costGrowth, owned)
if EconomyMath.trySpend(session.wallet, { [upgrade.currency] = price }) then
session.levels[upgradeId] = owned + 1
end
end)
```
- `trySpend` checks every currency in a multi-currency cost before subtracting any, and rejects
negative or non-finite costs.
- **Rebirth on the server**: compare the server's balance with the requirement, then reset wallet
and levels and increment the count. Never accept "I rebirthed" or a balance from the client.
- Rate-limit each remote per player with a token bucket (`roblox-networking`).
## Save data and offline time
- **Repair on load; never error.** Use `sanitizeAmount` (non-numbers, NaN, infinity and negatives
become 0; fractions are floored) and `sanitizeLevel` (also clamps to the current `maxLevel`).
Drop unknown upgrade ids and return a list of what was repaired, so old versions and edited data
load cleanly.
- **Never save NaN or infinity.** A division by zero that reaches the wallet should be caught by
the same sanitizers before saving.
- **Offline time comes from the server clock only**: save `os.time()` on the server when the
player leaves; on join, `away = os.time() - lastSeen`. A missing or future `lastSeen` counts as
now, which pays nothing.
- **Precision:** Luau numbers are doubles, exact for integers up to `2^53` (about `9e15`). If
balances can pass about `1e15`, store a mantissa/exponent pair or a base-10 logarithm instead.
## Big-number display
`EconomyMath.formatNumber(value, decimals?)` uses the suffixes K, M, B, T, Qa, Qi, Sx, Sp, Oc, No,
Dc, Ud, Dd, Td, Qad, Qid, Sxd, Spd, Ocd, Nod, Vg (`1e63`), then scientific notation.
- **The rollover bug:** `999950 / 1000 = 999.95`, which rounds to `"1000.0K"`. Round first; if the
rounded value reaches 1000, move to the next suffix (`"1.0M"`). The tests cover this case.
- Format on the client for display only; never parse a formatted string back into a number.
## Paid random items (eggs, crates, spins)
Roblox's [paid random items guidelines](https://create.roblox.com/docs/production/monetization/paid-random-items)
apply when a random item is bought with Robux, directly or through in-game currency bought with
Robux. Per that page:
- Show every possible outcome and its actual numerical odds, as percentages that sum to exactly
100%, before purchase (long decimals may be rounded, with a note that the shown total may not be
exactly 100%).
- Probability modifiers such as luck boosts and pity systems are covered: explain their effect
numerically before purchase, and update the shown odds live while a boost is active.
- If the full list does not fit, open it from a control labelled "Info" or "Details" that is
visible before purchase; an "(i)" icon without a word is not enough.
- `PolicyService:GetPolicyInfoForPlayerAsync()` returns `ArePaidRandomItemsRestricted`; for those
players, apply one of the treatments the page lists.
Build the odds panel with `EconomyMath.oddsPercent(outcomes)` and roll on the server with
`EconomyMath.pickWeighted(outcomes, rng:NextNumber())` from the **same** list (after applying any
boost), so shown and actual odds cannot drift.
## Pitfalls
- **Rebirth 6 takes weeks** → `requirementGrowth` far above the multiplier's growth; read the
`vs prev` column and lower `requirementGrowth` or switch to a multiplicative multiplier.
- **Prices outrun income after ~20 levels** → `costGrowth` of 1.5+ on an upgrade meant
to be bought 50 times (level 50 costs about 637 million times level 0); use 1.07-1.15.
- **Late game breaks after a few rebirths** → multiplicative multiplier (`1.5 ^ k`) without a
matching requirement curve; keep `requirementGrowth / q` near the ratio you want.
- **Players gain currency they did not earn** → a remote accepts an amount or price, or the check
happens only on the client; send ids, price on the server.
- **Two purchases pass one balance check** → the handler yields between check and spend; spend
before any `task.wait`, DataStore call, or other yield.
- **Buy max freezes the server** → uncapped loop or a price of 0; pass `maxLevel`, keep
`baseCost >= 1` (`maxAffordable` errors instead of looping forever).
- **Offline pays days of income** → client timestamp, no cap, or a corrupted `lastSeen`; use the
server clock, cap, and treat future times as now.
- **HUD shows "1000.0K"** → `math.floor(x * 10) / 10` before choosing the suffix; use
`formatNumber`.
- **A save fails or loads as garbage** → NaN or infinity reached the wallet; sanitize before save
and on load.
## Resources
- `scripts/balance.luau` — pacing calculator: per-level price, total, income and wait; per-rebirth
requirement, multiplier, wait and ratio; offline maximum. Run with `--help` first.
- `scripts/economy_math.luau` — the formulas, `trySpend`, sanitizers, odds and `formatNumber` as
pure Luau; runs in Lune and in a Roblox ModuleScript.
- `scripts/test_economy_math.luau` — tests for every function (`lune run`, exits 1 on failure).
- [references/formulas.md](references/formulas.md) — derivations, rebirth ratio, worked example.
- [references/server-patterns.md](references/server-patterns.md) — config layout, income tick,
buy max, rebirth, load repair with offline income, and paid random item rolls.
## Related skills
- `roblox-luau` — services, ModuleScripts, and the client/server split.
- `roblox-networking` — remote validation and token-bucket rate limits.
- `roblox-datastores` — loading and saving the session this skill repairs.
- `roblox-ui` — shop, upgrade, and odds panels.
- `roblox-studio-workflow` — Server & Clients playtests.
## Primary references
- `https://create.roblox.com/docs/production/monetization/paid-random-items`
- `https://create.roblox.com/docs/scripting/events/remote`
- `https://create.roblox.com/docs/scripting/security/client-server-boundary`
- `https://create.roblox.com/docs/reference/engine/classes/PolicyService`
- `https://create.roblox.com/docs/reference/engine/datatypes/Random`
- `https://lune-org.github.io/docs`