All extensions

Build a slot

Eight steps from an empty grid to a running server. Every piece is a pure function of a stream of floats, so the whole game replays from one recorded seed.

Pick a shape

Height per column. Ragged sets are ordinary, so this decision costs nothing now and everything later.
import { rect } from "@open-rgs/grid";

const SHAPE = rect(5, 3);

Draw the reels

s = 0
s = 0.8
Stickiness controls clumping. Frequency stays exactly where you set it.
=
Whatever the stickiness, the chain settles on the base distribution.
import { stackyFill } from "@open-rgs/markov";

const BASE  = { LOW: 47, MID: 24, HI: 12, PREM: 7, WILD: 6, SC: 0 };
const reels = stackyFill(SHAPE, BASE, 0.35);

Spawn the scatter

The count distribution IS the trigger rate - written down, not discovered.
One per reel keeps the count exact; excluded reels never receive one.
A wild about to pay is never eaten.
import { withScatters, oneInFor } from "@open-rgs/scatters";

const scatters = {
  symbol:   "SC",
  count:    { 0: 9000, 1: 700, 2: 250, 3: 45, 4: 5 },
  reels:    [1, 2, 3],
  protects: ["WILD"],
};

oneInFor(scatters, 3);

Name the boards

Every board comes from exactly one recipe, at its declared probability.
A tease is a real draw that goes through the pay evaluator honestly.
import { recipes, place } from "@open-rgs/recipes";
import { randomN, cols } from "@open-rgs/selectors";

const board = recipes([
  { name: "base",     p: 0.97, draw: withScatters(reels, scatters) },
  { name: "hi-tease", p: 0.03, draw: place(3, "HI", randomN(3, cols([0, 1])), reels) },
]);

Price the wins

A run is anchored at the leftmost reel and pays once, for its longest length.
=5×A
A wild-opening run pays whichever reading is worth more.
import { paytable, totalMultiplier } from "@open-rgs/paytable";
import { evalLines, rowLines } from "@open-rgs/pay-lines";

const roles = { wilds: ["WILD"], scatters: ["SC"] };
const LINES = rowLines(5, 3);

const PAY = paytable({
  LOW:  { 3: 0.2, 4: 0.8, 5: 3 },
  MID:  { 3: 0.4, 4: 1.5, 5: 6 },
  HI:   { 3: 0.8, 4: 3,   5: 12 },
  PREM: { 3: 1.5, 4: 5,   5: 22 },
  WILD: { 3: 2.5, 4: 10,  5: 45 },
});

Add the feature

3
Triggering is a separate question from paying.
A landing resets the respin counter, which is why the round needs a cap.
Filling the board pays the top tier.
import { triggersOn } from "@open-rgs/pay-anywhere";
import { beginRespins, stepRespins, isCycleOver, settleRespins } from "@open-rgs/holdwin";

const JACKPOTS = { MINI: 8, MINOR: 20, MAJOR: 60, GRAND: 400 };
const RESPINS  = { respins: 3, fullBoardAward: "GRAND" };

Write the math

The module default-exports a factory taking the host. That is the RNG seam: the math has no other source of randomness, and loadTsMath rejects a bare-object export for exactly that reason.

import type { MathHost, SimpleMath } from "@open-rgs/contract";

const MAX_WIN = 5000;

export default function createMath(host: MathHost): SimpleMath {
  return {
    kind: "simple", name: "demo", version: "1.0.0", rtp: 0.965,

    play() {
      const { grid, recipe } = board(host.rng_next);

      const wins = evalLines(grid, LINES, PAY, { roles });
      let mult = totalMultiplier(wins);

      if (triggersOn(grid, "SC", 3)) {
        let s = beginRespins(toCoinGrid(grid, host.rng_next), RESPINS);
        while (!isCycleOver(s)) s = stepRespins(s, landCoins(s.grid, host.rng_next), RESPINS);
        mult += settleRespins(s, RESPINS, JACKPOTS);
      }

      const capped = Math.min(mult, MAX_WIN);
      return {
        multiplier: capped,
        ops: [{ kind: "spin", grid: grid.cells, recipe }],
        type: capped > 0 ? "win" : "loss",
      };
    },
  };
}

Run it

import { createServer, binaryTransport, loadTsMath, cryptoRng } from "@open-rgs/core";
import { defineGame } from "@open-rgs/contract";
import { MockPlatform } from "@open-rgs/platform-mock";

await createServer({
  manifest: defineGame({
    id: "demo", declaredRtp: 0.965, defaultMode: "default",
    modes: {
      default: {
        math: await loadTsMath("./maths/demo.ts", { rng: cryptoRng }),
        stakeMultiplier: 1,
      },
    },
  }),
  platform:  new MockPlatform({ startingBalance: 100_000 }),
  transport: binaryTransport({ port: 80 }),
});

Where the rest of it lives

The walkthrough above builds one base game with a hold-and-win. The pieces for everything else it might grow are separate packages, each with its own page, and each an ordinary import:

A feature is a state machine your math steps through between the board and the win, so adding one changes the math file and nothing else: the manifest, the transport and the adapter never learn about it.

Then measure

Nothing above tells you the game is any good. Simulate it, and check the confidence interval before believing the number: a game with a rare feature paying hundreds of times bet needs tens of millions of spins to bound RTP usefully, and a point estimate from 400,000 will happily "confirm" anything within several points.

Every round needs a cap. A feature whose counter resets has no natural end.