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

GameOpenStepClose
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.