Math . simple

A simple round resolves in one math call. Use it for slots, instant-win, dice, plinko, and any game where the outcome is determined by a single roll.

Module shape

// maths/spin.ts
return {
  kind    = "simple",
  name    = "hello",
  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",
      -- carry?    = "<opaque string>",  -- threaded into next play()'s prev
      -- next_mode? = "freespins",       -- routes the next round
    }
  end,
}

Return shape

FieldPurpose
multiplierdimensionless win multiplier; 0 = loss. Core computes win = multiplier x bet
opsopaque visual instructions; the client replays them. Core forwards verbatim
typegame-defined tag (e.g. "win", "loss", "trigger_fs")
carryopaque string threaded into the next round's prev on this session
next_moderoutes the next round into a specific mode id

Context (ctx)

ctx = {
  mode    = "default",      -- resolved mode id (after promo / next_mode override)
  cheat?  = { force_win = true, force_coeff = 5 },   -- dev-only; set only when cheats explicitly enabled, never in prod
  params? = { mines = 3 },  -- free-form game params from the SPIN request
}

Host helpers

Two helpers are injected into every math VM. Extensions registered

HelperBehaviour
host.rng_next()returns a float in [0, 1) from the orchestrator's RNG (a secure CSPRNG by default; Math.random is never used)
host.log_debug(msg)writes a debug line tagged with the math file path
host.mark.count(name)increments a named counter, inert in production, recorded by the simulator
host.mark.observe(name, v)appends a value to a named histogram
host.mark.tag(name)tags the current spin under name
host.mark.contribute(name, m)adds a multiplier to a named RTP-attribution bucket

Currency-blindness

Math never sees balance, bet, or currency. The orchestrator multiplies the returned multiplier by the resolved bet (allowedBets[betIndex] x priceMultiplier x stakeMultiplier) and applies the manifest's max-win cap before settling.

Loading

import { loadTsMath } from "@open-rgs/core";

const math = await loadTsMath("./maths/spin.ts", {
  marks:      false,     // true to wire the simulator's mark collector
});

The loader computes the math's SHA-256 and stamps it on every round (visible in /healthz). Math version mismatch against stored session carry triggers the manifest.recovery policy.

Compiled math (WASM / Zig)

For production-grade or certification-bound math, compile a kernel to WebAssembly - typically in Zig (Rust, TinyGo, C also work) - and load it instead. Same MathModule contract; the orchestrator can't tell the difference. A WASM kernel runs Sandboxed and bit-deterministic (reproduce the benchmark with examples/twin-slot/src/bench.ts) and ships as one hashable artifact.

Build the kernel to WASM with zig, then point a manifest mode at the .wasm:

zig build-exe play.zig -target wasm32-freestanding -fno-entry -rdynamic -OReleaseSmall -femit-bin=play.wasm
import { loadWasmMath, createMathPool, cryptoRng } from "@open-rgs/core";

// Direct, synchronous calls - the fast path (trusted, bounded kernels).
const math = await loadWasmMath("./maths/play.wasm", { rng: cryptoRng });

// Off-thread + per-call timeout: an overrun FAILS THE ROUND (MATH_TIMEOUT).
// Worker-kill is best-effort/platform-dependent - keep kernels trusted.
const pooled = await createMathPool({
  wasmPath: "./maths/play.wasm", size: 4, timeoutMs: 1000,
});

A running WASM call can't be interrupted from JS, so bare loadWasmMath has no execution watchdog (it warns at load). createMathPool runs it off the I/O thread and fails the round on a per-call timeout (MATH_TIMEOUT), but killing a tight-loop runaway via terminate() is platform-dependent - keep WASM kernels trusted. See examples/hold-and-win for a worked Zig kernel (with its zig build-exe commands and a native multithreaded sim), and examples/twin-slot / examples/twin-gamble for the same math written in both Tya and Zig, with a test proving the two runtimes are 1:1.

RNG (secure by default)

The host injects randomness; the math never owns its PRNG. The default is a secure CSPRNG (cryptoRng, WebCrypto -> BoringSSL) - Math.random is never used for outcomes. In production the loader fails closed: you choose the source explicitly ({ rng: cryptoRng } or a certified source), even though a secure default exists.