You can build…
Each game shape is one Lua file with a different return shape. The boot file, transport, adapter, and admin stay the same.
A slot, instant-win, dice, plinko (simple)
One math call per spin. Return a multiplier and ops. Core multiplies by bet, settles via wallet in one RPC. Use this for anything where the outcome is decided by a single roll.
-- maths/spin.lua
return {
kind = "simple", name = "spin", version = "0.1.0", rtp = 0.95,
play = function(prev, ctx)
local r = host.rng_next()
local m = (r < 0.30 and 0.5) or (r < 0.40 and 2) or (r < 0.41 and 50) or 0
return {
multiplier = m,
ops = { { kind = "result", multiplier = m } },
type = m > 0 and "win" or "loss",
}
end,
}
Reel-based slots? Add the @open-rgs/ext-reels
extension for strip / payline / book-of helpers.
Deeper: simple math reference.
Mines, Chicken Road, Tower (complex, pick-style)
The bomb layout is rolled at open and committed to
state. Each step reveals one cell or advances one
lane. close cashes out the current multiplier. A
bomb hit makes the round terminal — the client then sends CLOSE
to settle the bust.
return {
kind = "complex", name = "mines", version = "0.1.0", rtp = 0.97,
open = function(_prev, ctx)
-- roll bomb grid using host.rng_next(), commit to state
return { state = json_encode({ bombs = ..., picks = {} }),
ops = { { kind = "open", grid = 25 } },
awaiting = { type = "pick_cell", prompt = "Pick a tile" } }
end,
step = function(state, action)
-- decode state, reveal action.cell, grow multiplier or bust
-- absent awaiting → terminal → client must CLOSE
end,
is_terminal = function(state) return ... end,
close = function(state) return { multiplier, ops, type = "cashout" } end,
} Cash-out is a CLOSE, not a STEP. STEP is reserved for actions that change game state; CLOSE settles. Full working Mines: complex math reference.
Crash (Aviator-style)
Single OPEN. Math rolls the crash multiplier, then streams a
tick sequence as ops. No STEP at all. The client
sends CLOSE the instant the player taps "cash out". If the player
never taps, an external autocloseRequested event
(from the wallet) or POST to /admin/autoclose fires
the close at the crash point.
open = function(_prev, _ctx)
local crash_at = roll_crash_curve() -- e.g. 1.00 .. ∞
local ticks = {}
for t = 1.00, crash_at, 0.01 do ticks[#ticks+1] = { kind = "tick", x = t } end
return {
state = json_encode({ crash_at = crash_at, settled = false }),
ops = ticks, -- client animates them
awaiting = { type = "cash_out", prompt = "Cash out before crash" },
}
end,
is_terminal = function(s) return json_decode(s).settled end,
close = function(s) return { multiplier = read_current_tick(s), ... } end,
autoclose = function(s) return { multiplier = 0, type = "crash", ... } end, Gamble / double-or-take (inside a slot)
A complex round seeded with the previous win. STEP carries
{ type = "gamble" | "take" }. "take" closes
immediately at the current multiplier; "gamble" rolls and either
doubles or busts. Use it as a follow-up round after a normal slot
spin (next_mode = "gamble" on the slot's win).
return {
kind = "complex", name = "gamble", version = "0.1.0", rtp = 1.0,
open = function(prev, _ctx)
local seed = tonumber(prev) or 0
return {
state = json_encode({ mult = 1, base_win = seed }),
ops = { { kind = "gamble_open", base_win = seed } },
awaiting = { type = "gamble_action", options = { "gamble", "take" } },
}
end,
step = function(state, action)
local s = json_decode(state)
if action.type == "gamble_action" and action.choice == "take" then
return { state = json_encode(s) } -- terminal (no awaiting)
end
if host.rng_next() < 0.5 then s.mult = s.mult * 2
else s.mult = 0; s.busted = true end
return {
state = json_encode(s),
ops = { { kind = "gamble_step", mult = s.mult } },
awaiting = not s.busted and { type = "gamble_action", options = { "gamble", "take" } } or nil,
}
end,
is_terminal = function(s) local d = json_decode(s); return d.busted or d.taken end,
close = function(s) local d = json_decode(s); return { multiplier = d.mult, ... } end,
} Feature buys (ante, buy bonus)
Buys are just another mode in the manifest with a higher
stakeMultiplier. The math can branch on
ctx.mode, or route into an internal "freespins" mode
via next_mode on the trigger round.
defineGame({
id: "scarlet-pact", declaredRtp: 0.96, defaultMode: "default",
modes: {
default: { math, stakeMultiplier: 1, declaredRtp: 0.959 },
ante: { math, stakeMultiplier: 1.25, declaredRtp: 0.966, label: "Ante" },
buy: { math, stakeMultiplier: 59, declaredRtp: 0.958, label: "Buy FS" },
freespins: { math: fsMath, stakeMultiplier: 0, internal: true },
},
}); internal: true hides the mode from the client's mode
catalog — it's only reachable when another math returns
next_mode = "freespins". Deeper:
boot reference.
What stays the same
- The boot file (
createServer+defineGame) is identical regardless of game shape. - The wire protocol is the same set of frame codes for every game.
- The wallet adapter is unchanged — it doesn't care what the math does.
- Admin, probes, metrics, logs, autoclose: all wired by core.