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