Math · complex
A complex round opens, then waits for player input one or more times before closing. Use it for Mines, Chicken Road, gamble / double-or-take, hold-and-spin, pick bonuses, and crash games.
Lifecycle
sequenceDiagram
autonumber
actor Client
participant RGS
participant Math as math.lua
participant Wallet
Note over Client,Wallet: openRound — money moves
Client->>RGS: OPEN
RGS->>Wallet: debit
RGS->>Math: open(prev, ctx)
Math-->>RGS: state, ops, awaiting?
RGS-->>Client: opened
loop step (× N) — in-process, no wallet
Client->>RGS: STEP action
RGS->>Math: step(state, action)
Math-->>RGS: state, ops, awaiting?
RGS-->>Client: ack
end
Note over Client,Wallet: closeRound — money moves
Client->>RGS: CLOSE
RGS->>Math: close(state)
Math-->>RGS: multiplier, ops
RGS->>Wallet: credit
RGS-->>Client: closed
Money moves twice at most: openComplex at open,
closeComplex at close. Every STEP is pure in-process —
no wallet calls. State is held in-memory on the RGS as an opaque
RoundState string; math owns its shape.
Module shape
return {
kind = "complex", name = "mines", version = "0.1.0", rtp = 0.97,
open = function(prev, ctx) return { state, ops, awaiting? } end,
step = function(state, action) return { state, ops, awaiting? } end,
close = function(state) return { multiplier, ops, type, carry?, next_mode? } end,
is_terminal = function(state) return boolean end,
-- autoclose? = function(state) ... end -- optional resolver
} Awaiting hint
Present → orchestrator stays open, client must STEP.
Absent → is_terminal must return true
and the client must CLOSE.
awaiting = {
type = "pick_cell", -- enforced; mismatched STEP → INVALID_ACTION
options = { 0, 1, 2, ..., 24 }, -- optional whitelist
deadline = 30000, -- optional ms before autoclose fires
prompt = "Pick a tile",
} Mines (full reference)
25 cells, N mines, RTP-tuned multiplier ladder. open
rolls the bomb grid and commits it to state. Each
step reveals one cell; a bomb ends the round, a safe
pick grows the multiplier. close cashes out the
current ladder value. The grid is committed once, so every pick is
provably-fair against a single roll.
-- maths/mines.lua
local cjson = require("cjson")
local LADDER = { -- multipliers[mines_count][picks_safe]
[3] = { 1.06, 1.18, 1.32, 1.49, 1.69, 1.94, 2.25, 2.62, --[[ ... ]] },
[5] = { 1.13, 1.34, 1.61, 1.95, 2.39, 2.97, 3.74, 4.78, --[[ ... ]] },
}
local function roll_bombs(n_mines)
local cells, bombs = {}, {}
for i = 0, 24 do cells[#cells+1] = i end
-- Fisher-Yates with the orchestrator's RNG
for i = 25, 2, -1 do
local j = 1 + math.floor(host.rng_next() * i)
cells[i], cells[j] = cells[j], cells[i]
end
for i = 1, n_mines do bombs[cells[i]] = true end
return bombs
end
return {
kind = "complex", name = "mines", version = "0.1.0", rtp = 0.97,
open = function(_prev, ctx)
local mines = (ctx.params and ctx.params.mines) or 3
if not LADDER[mines] then mines = 3 end
local bombs = roll_bombs(mines)
local state = cjson.encode({ mines = mines, bombs = bombs, picks = {}, busted = false })
return {
state = state,
ops = { { kind = "open", mines = mines, grid = 25 } },
awaiting = { type = "pick_cell", prompt = "Pick a tile" },
}
end,
step = function(state_json, action)
local s = cjson.decode(state_json)
local cell = action.cell
if type(cell) ~= "number" or cell < 0 or cell > 24 then
error("INVALID_ACTION: bad cell")
end
s.picks[#s.picks + 1] = cell
if s.bombs[tostring(cell)] or s.bombs[cell] then
s.busted = true
return {
state = cjson.encode(s),
ops = { { kind = "reveal", cell = cell, bomb = true } },
-- no awaiting → round terminal; client must CLOSE
}
end
local safe_picks = #s.picks
local m = LADDER[s.mines][safe_picks] or 0
return {
state = cjson.encode(s),
ops = { { kind = "reveal", cell = cell, bomb = false, multiplier = m } },
awaiting = { type = "pick_cell", prompt = "Pick again or cash out" },
-- The client signals "cash out" by sending CLOSE instead of STEP.
}
end,
is_terminal = function(state_json)
local s = cjson.decode(state_json)
return s.busted or #s.picks >= (25 - s.mines)
end,
close = function(state_json)
local s = cjson.decode(state_json)
local m = s.busted and 0 or (LADDER[s.mines][#s.picks] or 0)
return {
multiplier = m,
type = m > 0 and "cashout" or "bust",
ops = { { kind = "settle", multiplier = m } },
}
end,
} Other instant-game patterns
| Game | Open | Step | Close |
|---|---|---|---|
| Chicken Road | roll bomb-lane sequence | { type = "cross" } advances one lane | cash out current multiplier |
| Tower / Limbo | roll safe column per row | { type = "pick", col = N } | cash out current row |
| Crash (Aviator-style) | roll crash multiplier, emit tick stream as ops | — | cash out at current tick |
| Gamble / double-or-take | seed with previous win | { type = "gamble" | "take" } | settle final multiplier |
| Pick bonus (slot feature) | roll prize pool | { type = "pick", idx = N } | sum revealed prizes |
Crash games skip STEP entirely: one OPEN, the client streams the tick visual, then sends CLOSE the instant the player taps "cash out". If the player never taps, the wallet (or admin) fires an external autoclose — see Admin.
Reconnect & resume
If a player drops mid-round and reconnects with the same session,
the next INIT_RESPONSE carries a resume
payload: full cumulative ops, the player's
actionLog, and the current awaiting
hint. The client replays ops to rebuild visuals, then renders the
awaiting prompt.
Autoclose
RGS-side autoclose is never timer-driven. It is
always initiated by an external signal: a
autocloseRequested platform event from the wallet, or
a POST to /admin/autoclose. The resolver is
math.autoclose(state) if defined, otherwise the
manifest's autoclose.policy.