@open-rgs/freespins
A spin is paid at the multiplier that was showing.
A free-spin round is a small state machine: some number of spins, a running total, and whatever persists between them. It does not spin anything. The board still comes from your generator and the win from your evaluator; this owns the bookkeeping in between, which is where the arithmetic errors live.
The whole feature
import { spinsFor, beginFreeSpins, runFreeSpins, applySticky } from "@open-rgs/freespins";
const TABLE = { 3: 10, 4: 15, 5: 25 }; // scatters -> spins
const CFG = { ladder: { steps: [1, 2, 3, 5] } }; // global multiplier
const feature = runFreeSpins(
beginFreeSpins(spinsFor(TABLE, scatters), CFG),
(state, next) => {
const board = applySticky(drawBoard(next), state.sticky);
return { win: evaluate(board) }; // before the multiplier
},
host.rng_next,
CFG,
);
feature.total; // a multiple of bet, multipliers already applied The trigger is a table
Scatters landed to spins awarded, written the way the paytable states it and the way a lab reads it back. Counts above the top entry award the top entry: six scatters on a table that stops at five is a better board, not a broken one, and paying it nothing is the kind of bug that only appears on the boards players remember.
spinsFor({ 3: 10, 4: 15, 5: 25 }, 3); // 10
spinsFor({ 3: 10, 4: 15, 5: 25 }, 6); // 25, the top entry
triggers({ 3: 10 }, 2); // false
triggerAt({ 3: 10, 4: 15 }); // 3, the number a tease is built around The multiplier survives the spin that earned it
The global multiplier lives in the feature's state, and playSpin applies it before the ladder moves. That order is the point: a spin pays at the value that was showing when it was played. The other order pays the first spin at the second spin's multiplier, compounds over the whole feature, and looks perfectly right in a screenshot.
const CFG = { ladder: { steps: [1, 2, 3, 5] } }; // last step repeats
let s = beginFreeSpins(4, CFG); // showing 1x
s = playSpin(s, 10, CFG); // pays 10, ladder moves to 2x
s = playSpin(s, 10, CFG); // pays 20, ladder moves to 3x
// advance: "per-win" holds the ladder still on a losing spin
// advance: "manual" leaves it to bumpMultiplier / setMultiplier per-spin climbs every spin. per-win climbs only on a spin that paid, which is the version that makes a dry spell hurt. manual hands it to the game, for a symbol that bumps the multiplier when it lands.
A retrigger adds, it never resets
Which is why a retrigger late in a feature is worth more than an early one, and why the counter is worth watching rather than just the count.
s = retrigger(s, spinsFor(TABLE, scatters)); // adds to spinsLeft
// a feature that can retrigger indefinitely needs a bound of its own
const CFG = { ladder, maxRetriggerSpins: 60 }; The max-win cap is a payout rule, not a statement about how long a round runs, so maxRetriggerSpins exists to say the second thing. runFreeSpins also takes a maxSpins backstop, so a retrigger rule that always fires cannot hang a round.
Sticky cells are re-applied, not remembered
A held cell is written onto every board that follows, which means calling applySticky after the draw and before the evaluation. A sticky symbol that is only written on the spin it landed is not sticky, and nothing else in the round will tell you.
// inside the spin function
const board = applySticky(drawBoard(next), state.sticky);
// hold what landed, for the spins that follow
return { win: evaluate(board), stick: [[{ col: 2, row: 1 }, "WILD"]] }; Sticking the same cell twice replaces rather than stacking, because two symbols cannot occupy one position and quietly keeping the older one is the surprising half of that.
What a spin can report
Most spins are just a win. The rest of the outcome is optional, and the loop applies it in the order a round actually happens: pay, then extend, then hold.
return {
win: 12, // before the global multiplier
retrigger: spinsFor(TABLE, scatters), // extends the feature
stick: [[pos, "WILD"]], // held for the spins that follow
bump: 1, // for a "manual" ladder
};