This folder is meant to be shared with the community so anyone can independently validate the published LimeCasino odds.
Any changes we make to casinos in-game will be reflected in this repository so you can continue to validate.
| File | Role |
|---|---|
tools/casino_math.py |
Exact RTP + Monte Carlo + item hunt calculator |
presets/*.json |
Canonical machine configs (what staff load in the toolgun) |
| This README | How the Python mirrors the live Lua, and how to run checks |
You do not need Garry’s Mod installed. You need Python 3.10+ (stdlib only).
cd addons/pcasino/tools
python3 casino_math.py --spins 200000
python3 casino_math.py --list-items
python3 casino_math.py --hunt bmwm3gtr46 --skip-rtp
python3 casino_math.py --hunt kawasakininja --machine advanced_low --machine advanced_mid --machine advanced_high --skip-rtpTwo maintenance commands rewrite the presets rather than just reporting on them:
python3 casino_math.py --refresh-presets --skip-rtp # recompute published math/hunt from settings
python3 casino_math.py --retune-advanced --skip-rtp # solve advanced cash p for target_rtp, then refresh--refresh-presets is the one to reach for after any settings change. The math block is what /odds renders in-game, so it goes stale the moment weights, combos, wheel segments — or the payout-selection rules themselves — change, and nothing else catches that.
-
Basic slots target ~95% cash RTP; advanced machines target ~92%. Each preset carries its own
target_rtpand the validator checks against that, not one global number. -
Mystery Wheel items are not cash. Cars / Dragon’s Breath / Rolex / etc. are prestige. Bound prizes use hidden
tradable=falseitem meta so they cannot be given, dropped, or inventory-sold (Trabbi is the exception — still tradable as the joke prize). -
Line payouts are capped: 40× stake on advanced machines, 60× on basic. Basic slots pay no items, so they keep a bigger headline prize; below ~60× the cap stops helping them anyway, because the jackpot pot becomes the binding tail. Frequent low-multiplier pays buy the RTP back at the bottom. The uncapped advanced tables could pay $3.03M from a single spin.
The cap clamps the reel line only. The jackpot pot and the Mystery Wheel's $500,000 segment are paid by separate code paths and are not bounded by it — on Adv Low that segment alone is 100× stake, 2.4× the capped line.
-
Cheaper machines are slower, not cheaper. Chest weights are tuned per machine (7 / 14 / 34 on Low / Mid / High) so that expected cash wagered per single-segment Mystery item is equal on all three — see below.
-
Losing is part of the design. The mini-wheel keeps one
Nothingsegment, the Mystery Wheel keeps oneNothingplus the$1and Trabant joke prizes. A bonus that always pays is not exciting — there has to be a real chance of walking away with nothing. -
Basic-slot jackpots reset often enough to stay a prize, not a lottery. They fire roughly 1 spin in 1,260 / 946 / 729 on Low / Mid / High, holding the pot near 50× stake instead of the 942–5005× it reached when the pot ran for tens of thousands of spins. The pot is a running counter —
startValue + bet × betAdd × spins_since_last_hit— so its size is set by how often it resets, and it is paid outside the line cap.
Hunt costs are equalised across the advanced machines. Expected cash wagered for a specific single-segment Mystery item is ≈ $50.1M / $49.3M / $51.3M on Low / Mid / High (a 1.04× spread), for an expected net cash cost of ≈ $4.0M per item on any of them.
All three run 1/12 Big Wheel mini-segments, so equalisation comes entirely from the chest weight: per-spin bonus rate is held proportional to the stake (2.16% / 4.38% / 10.53% against bets of $5k / $10k / $25k). The residual spread is integer granularity — one unit of chest moves Adv Mid by about 7%, so 1.04× is the closest reachable without inflating every weight on the reel.
These figures dropped from ≈$55M in earlier revisions of this doc. That is the tie-break fix landing: chest combos used to lose the payout comparison to overlapping cash combos, so roughly a tenth of qualifying spins never awarded the mini-wheel at all. Paying every chest that lands raises the real bonus rate, which shortens the hunt. The chest weights themselves are unchanged.
One interaction worth knowing: the bonus rate also sets the jackpot pot. The pot grows by bet * betAdd every spin and only resets when the mini-wheel lands its jackpot segment, so a rarer bonus means a longer accumulation. At these weights the pot averages $88k / $92k / $110k — about 18× / 9× / 4× the stake. Push the bonus rate much lower and the pot balloons past the 40× line cap and becomes the dominant tail again; at a 0.28% bonus on Adv Low it reached $649k, or 130× stake.
pCasino does not use real reel strips or a hidden RTP field. Odds are whatever weights/combos/wheel segments are saved on the entity. Our presets encode those settings in JSON; the simulator implements the same rules as the Lua.
Lua (pcasino_slot_machine / pcasino_wheel_slot_machine StartRound):
CanAfford→AddMoney(-bet)- If jackpots enabled:
jackpot += bet * betAdd
Python: every simulated spin spends bet and grows the pot the same way.
Lua (GenerateResult):
-- chance[symbol] is an integer weight
for symbol, weight in pairs(self.data.chance) do
for i = 1, weight do table.insert(pool, symbol) end
end
return table.Random(pool) -- independent draw per reelPython: build the same multiset pool; random.choice three times.
Per-reel probability of symbol s is weight[s] / sum(weights).
Lua (CheckForCombo): walk the combo list; keep the best match. Patterns may use "anything". If jackpots are on, a jackpot/j=true combo beats a cash combo when both match (chest / dollar lines).
Python: pick_combo() copies that preference order (j preferred, else higher p).
The jackpot preference has to hold regardless of which combo the walk reaches first. Jackpot combos carry p = 0 (their reward is the wheel spin, not cash), so a "higher p wins" rule alone always hands the spin to an overlapping cash combo. coin, coin, chest matches both coin-coin-anything and anything-anything-chest; before this was fixed the cash line won and the mini-wheel was silently dropped. Both sides now short-circuit on j before comparing p.
Lua:
baseWinnings = bet + bet * tonumber(win.p) -- i.e. bet * (1 + p)
AddMoney(baseWinnings)Python: same multiplier.
p = 0.5 ⇒ get 1.5× stake back (net +0.5 bet if you ignore the stake already taken).
Lua:
- Each spin contributes
bet * betAddinto the pot. - On a jackpot award, pay
GetCurrentJackpot(), then reset pot tostartValue.
That reset creates startValue dollars every cycle (not player money). Exact RTP must include:
jackpot RTP = betAdd + P(jackpot award) * startValue / bet
Python uses that formula (basic slots) and the equivalent pot average for mini-wheel jackpot segments (advanced).
On a basic slot the jackpot combo pays the line and the pot — AddMoney(bet + bet*p) runs before AddMoney(jackpot) — so the stake comes back on top of the pot. Every jackpot combo in these presets has p = 0, making that exactly one stake. Advanced machines differ: the chest pays no line cash at all, it only queues the mini-wheel.
Lua (pcasino_wheel_slot_machine StartSpin):
local result = math.random(12)
local winData = self.data.wheel[result]
RewardsFunctions[winData.f](ply, ent, winData.i)Chest combos (j=true) do not pay line cash; they queue this wheel.
f |
Meaning in our presets |
|---|---|
money |
Pay i dollars |
jackpot |
Pay machine pot, reset to startValue |
nothing |
No cash (one segment, kept deliberately) |
prize_wheel |
Grant one Mystery / Big Wheel free spin |
Python: math.random(12) → rng.randrange(12); same reward table.
Segment order is cosmetic. math.random(N) draws a uniform index, so no position is more or less likely than any other — reordering the wheel array changes nothing about RTP, hit rate, or item odds. It only changes which segment a player sees next to which as the needle spins past. The mini-wheel is arranged [lowest filler] → Jackpot → Nothing → Big Wheel → [lowest filler] → … (interleaving the remaining cash tiers low/high) so the two rare, exciting outcomes are framed by the two least eventful ones, then the rest alternates small/large cash going around. Confirmed order-invariant by rebuilding math from the reordered array and diffing against the original — RTP matched to the same six decimal places both times.
Two of the twenty segments are Spin Again, so an activation resolves against 18 absorbing segments. Published per-segment odds are quoted as k/18 — the chance the activation eventually lands that segment, which is what a player experiences. Quoting k/20 understates every real rate.
Lua: math.random(20) over data.wheel. Our preset sets buySpin.buy = false.
Spin Again is f = "prize_wheel" again (recursive free spin).
Python cash EV of one activation (items = $0):
E_cash = (sum of money segment amounts) / (20 - number_of_spin_again_segments)
Probability a free spin eventually awards a specific item with k matching segments and r Spin Again segments:
P(item | free spin) = k / (20 - r)
(With one M3 segment and two Spin Again segments: 1/18.)
Segment order here is cosmetic too, same reasoning as the mini-wheel. The wheel opens with a fixed ten-segment run through the named prizes (Golden Vape, $25k, M3 GTR, Magical Cake, i8, $1, Ninja, $50k, Dragon's Breath, Trabbi — deliberately mixing grand prizes with the $1/Trabbi joke outcomes, not clustering the "good" ones together), then the remaining ten alternate cash/Rolex wins with Nothing/$5k/Police Uniform/Magical Cake letdowns, with the two Spin Again segments five slots apart (a quarter of the wheel) so hitting one is never a sign the other is close.
On an advanced machine:
P(item on one paid spin)
= P(mini-wheel) -- chest appears (bonus_rate)
* (BigWheelSegments / 12) -- mini lands prize_wheel
* P(item | Mystery activation) -- absorbing prob above
Then:
E[spins until first item] = 1 / P
E[money wagered] = E[spins] * bet
E[cash returned] = E[wagered] * RTP
E[net cash] = E[cash returned] - E[wagered] # ≈ -8% of wagered (advanced)
Geometric distribution ⇒ median spins ≈ ln(2) / P (often much lower than the mean).
python3 casino_math.py --spins 500000Prints exact RTP, hit rate, volatility label, Monte Carlo check, a session P&L spread, and a rough floor stress mix. Exit code 1 if any machine is outside its own target_rtp ± 0.5pp (95% for basic slots, 92% for advanced).
RTP is a long-run average. It says nothing about what a single session looks like, and an RP economy experiences sessions, not limits. Every machine now also reports the distribution of house profit over a session:
advanced_high bet=$25,000 RTP=92.00% hit=30.4% vol=medium (CV=3.74)
session P&L (1,000 spins = $25,000,000 wagered, 2,000 sims):
worst 1% $ -4,901,370 p05 $ -2,569,321 median $ +2,192,985 p95 $ +6,440,115
mean $ +2,018,340 (8.07% of wagered) P(house down) = 24.1%
Read that as: the house still finishes down on 24.1% of thousand-spin sessions, with a 1-in-100 session costing $4.9M. That is after capping lines at 40×; the uncapped 95%-RTP table was down on 38.6% of sessions with a 1-in-100 session over $10M, because a 121.2× top line on a $25,000 stake pays $3.03M from one spin.
python3 casino_math.py --session-spins 2000 --session-trials 5000 # deeper sample
python3 casino_math.py --session-trials 0 # skip it (faster)Defaults are 1,000 spins × 2,000 simulated sessions per machine, which is roughly one dedicated player for an hour.
How it works: the tool builds the exact finite distribution of house profit for a single spin — every reel combo, every mini-wheel segment, and the Mystery Wheel's absorbing cash distribution — then samples sessions from it. Jackpots are drawn from their real steady-state distribution (start + step × G, G ~ Geometric(p_award)) rather than pinned at the average, so pot-driven tails show up honestly. The distribution's analytic mean is asserted against the exact RTP on every run; a mismatch fails the tool rather than printing a plausible-looking percentile.
python3 casino_math.py --list-items
python3 casino_math.py --hunt bmwm3gtr46 --skip-rtp
python3 casino_math.py --hunt vape_dragonsbreath --skip-rtp --hunt-trials 3000
python3 casino_math.py --hunt bmwm3gtr46 --machine advanced_high --skip-rtpShows, per advanced machine:
- chance per spin / “1 in N”
- exact expected spins, wagered, cash back, net cash
- exact median estimate
- optional Monte Carlo distribution (mean / median / p25 / p75)
python3 casino_math.py --emit-lua ../lua/perfectcasino/config/sh_presets.lua --skip-rtpJSON is canonical; sh_presets.lua is a generated mirror for the game.
Each presets/<id>.json has:
settings— exact pCasino toolgun payload (bet,chance,combo,jackpot,wheel, …)math— published RTP / payline probabilities (what/oddsshows in-game)hunt(advanced) — equalised expected wager for a single-segment item (target_expected_wager_per_single_segment_itemis the design target the chest weights are solved against).
Machine ids:
basic_low, basic_mid, basic_high, advanced_low, advanced_mid, advanced_high, mystery_wheel
Why isn’t Adv Low the same chance as Adv High?
In expected dollars it is. All three run 1/12 Big Wheel mini-segments, and the chest weight is scaled with the stake (7 / 14 / 34 against $5k / $10k / $25k) so the bonus rate stays proportional to what you are betting. Per-spin odds are therefore worse on the cheap machines, which is what keeps expected dollars-per-item aligned.
Was it always equalized?
Mostly. An earlier draft made Adv Low the most expensive hunt; later ones equalised all three, first at ≈ $7.5M wagered per single-segment item and now at ≈ $58M after the Big Wheel dropped to 1/12 and the chest weights were scaled down. The mechanism changed along the way — it used to be a 3/2/1 Big Wheel split paired with chest weights, and is now chest weight alone.
Does winning the M3 count as cash RTP?
No. You still “pay” via the ~5% house edge on all the cash you cycled to get there.
What about the $500k Mystery cash prize?
It’s real money and is included in Mystery cash EV (~$49k per spin including respins). Advanced machines are tuned so overall cash RTP lands on ~92%.
Casino-bound items
i8, Gold Rolex, Stolen Police Uniform, and Magical Cake are granted with hidden metadata tradable=false. Give / drop / inventory-sell are blocked. Trabbi is not bound. M3 / Ninja / Dragon’s Breath / Golden Vape are already locked in their item definitions.
Can I change the JSON and re-check?
Yes. Edit a preset, re-run casino_math.py. If you only care about hunts: --hunt … --skip-rtp.
In-game /odds
Shows the same published math block players get from these files — so the sheet and the game stay in sync when staff place machines from these presets.