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
| Field | Purpose |
|---|---|
multiplier | dimensionless win multiplier; 0 = loss. Core computes win = multiplier x bet |
ops | opaque visual instructions; the client replays them. Core forwards verbatim |
type | game-defined tag (e.g. "win", "loss", "trigger_fs") |
carry | opaque string threaded into the next round's prev on this session |
next_mode | routes 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
| Helper | Behaviour |
|---|---|
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.