@open-rgs/holdwin
A landing resets the counter.
Everything the genre puts on top of that rule is here: coins that collect, pay, multiply and upgrade; face-down coins that turn over at the end; factors printed on cells; spins awarded on top of the counter; and a board that grows while the cycle is running. Each is a small function over the board, so a game composes the ones it wants.
The cycle
N coins on a base board trigger respins. Coins lock, and every new coin resets the counter rather than decrementing it. That is why a cycle has no natural end, and why the max-win cap is what makes the round finite.
import { triggers, beginRespins, runRespins, landCoins, settleRespins, coinSet } from "@open-rgs/holdwin";
const CONFIG = { respins: 3, fullBoardAward: "GRAND" };
const JACKPOTS = { MINI: 8, MINOR: 20, MAJOR: 60, GRAND: 400 };
const COINS = coinSet({ 1: 60, 2: 25, 5: 12, 10: 3 }); // value: weight
if (triggers(board, 6)) {
const s = runRespins(
beginRespins(board, CONFIG),
CONFIG,
(grid, next) => landCoins(grid, 0.14, COINS, next),
host.rng_next,
);
const win = settleRespins(s, CONFIG, JACKPOTS); // a multiple of bet
} That is a whole feature. runRespins is the loop (spin, apply, stop when the counter runs out or the board fills) and landCoins is the draw. Both are below, because both have a decision in them.
Where the coins come from
Only empty cells are considered, because a locked coin never moves: a landing aimed at an occupied cell is discarded rather than overwriting it. Which cells get a chance, and how many, is the feature's distribution, so the two shapes the genre uses both ship here.
import { landCoins, landCount, countSet } from "@open-rgs/holdwin";
// one independent trial per empty cell, at 14%
landCoins(grid, 0.14, COINS, next);
// or: a drawn count, dropped into empty cells at random
landCount(grid, 2, COINS, next);
landCount(grid, countSet({ 1: 50, 2: 30, 3: 20 }), COINS, next); // weighted count They are different games rather than different spellings. Per-cell trials make landings scale with how empty the board is, so a nearly full board rarely takes another coin and cycles end on their own. A drawn count keeps the rate flat until there is nowhere left to put a coin, so cycles run longer and far more of them reach a full board, which moves the largest single term in the feature's RTP. landCount places what fits rather than throwing when the board is nearly full.
Face-down coins need no separate path: put mystery() in the coin set and they land at their own weight.
import { sampler } from "@open-rgs/weights";
import { coin, mystery } from "@open-rgs/holdwin";
const COINS = sampler([
{ item: coin(1), weight: 60 },
{ item: coin(5), weight: 12 },
{ item: mystery(), weight: 8 },
]); Coins that do something
Collector, payer, multiplier, upgrader and spawner are one mechanism with different selectors: a source cell, a set of target cells, an operation. Each returns an Effect, and your math decides when to run it.
import { collector, payer, multiplier, upgrader, spawner,
coinCells, cashCells, tierCells, coinSet } from "@open-rgs/holdwin";
import { randomN } from "@open-rgs/selectors";
collector(coinCells(), JACKPOTS)(grid, at, next); // absorb the board into this cell
payer(5, coinCells())(grid, at, next); // +5x onto every other coin
multiplier(3, randomN(2, cashCells()))(grid, at, next); // the "sniper": scale 2 at random
upgrader(tierCells(["MINI"]))(grid, at, next); // MINI -> MINOR
spawner(2, coinSet({ 1: 1 }))(grid, at, next); // two new coins in empty cells Timing is the design decision, not a detail. A collector that fires the moment it lands harvests a nearly empty board; the same collector run at the end of the cycle harvests everything. A collected coin is emptied rather than removed, so it still fills its cell for the full-board award and cannot be harvested twice.
Tiers are deliberately left alone by payer and multiplier: a "2x GRAND" is not a rung on the ladder. Scale cash, upgrade tiers.
Mystery coins
A mystery coin lands face down. It takes its cell and resets the counter like any other coin, and it has no value until the reveal, which is usually the last thing before settle.
import { revealMystery, mysteryCells } from "@open-rgs/holdwin";
// end of the cycle: turn them over, THEN collect and settle
const revealed = revealMystery(s.grid, PRIZES, host.rng_next); // one value for all
const each = revealMystery(s.grid, PRIZES, host.rng_next, { shared: false }); // one each One value or one each. Turning every hidden cell over to the same coin is the genre convention, and it is a variance decision rather than a cosmetic one. It pays the same on average as drawing per cell and swings much harder: over 200,000 reveals with four hidden cells, mean 10.10 against 10.11, standard deviation 14.04 against 7.01.
Reveal before anything reads a value. Asking what a face-down coin is worth throws, so a collector or a settle that runs too early fails loudly instead of harvesting the board for nothing:
valueOf: this coin is still face down. Call revealMystery(grid, coins, next)
before anything reads values - a collector or a settle running first would
price the whole board at zero. Multipliers on coins, multipliers on cells
Two mechanics share the word. A multiplier coin scales other coins and is an effect. A factor printed on a cell belongs to the board: whatever finishes there is worth more, and a second multiplier landing on the same cell raises the factor rather than replacing the coin.
import { noCellMultipliers, addCellMultiplier, cellFactorAt, settleRespins } from "@open-rgs/holdwin";
let cells = noCellMultipliers();
cells = addCellMultiplier(cells, { col: 1, row: 1 }, 2); // x2 lands
cells = addCellMultiplier(cells, { col: 1, row: 1 }, 3); // -> x5
cells = addCellMultiplier(cells, { col: 1, row: 1 }, 3, "multiply"); // -> x15
cells = addCellMultiplier(cells, { col: 1, row: 1 }, 3, "replace"); // -> x3
settleRespins(s, CONFIG, JACKPOTS, { cellMultipliers: cells }); How a second factor combines is the whole top of the distribution, so it is spelled out rather than assumed: add is the default, multiply is the aggressive one, replace keeps the best single hit. Factors are keyed by position, so they survive a board that grows.
The interaction to simulate before shipping is the collector standing on a multiplied cell: it multiplies the merged total, not one coin. On a three by three board of 2x coins with a 3x factor on the centre cell, settling pays 22; running the collector into that cell first pays 54. The factor did not change, the thing it multiplies did. That is where max-win boards come from, and the reason the round still needs maxWinMultiplier whatever the paytable says.
The full-board award is not scaled by a cell factor, since it belongs to the board rather than to a cell.
Extra spins, and a board that grows
A landing resets the counter to the configured number. A "+1 spin" symbol adds to whatever is left, which is why it is worth most late in a cycle, and that is the tease it exists to create.
import { awardRespins } from "@open-rgs/holdwin";
s = awardRespins(s, 1); // 1 left -> 2 left; a landing would have made it 3 Games also unlock rows partway through, usually on a coin count. Locked coins keep their cell, the new cells open empty and immediately become landing targets, and full is recomputed against the bigger board, so an expansion pushes the full-board award further away and lengthens the cycle.
import { runRespins, expandBoard, awardRespins, coinCount } from "@open-rgs/holdwin";
import { rect } from "@open-rgs/grid";
// onSpin is where anything that changes the cycle mid-flight belongs
runRespins(beginRespins(board, CONFIG), CONFIG, land, host.rng_next, {
onSpin: (s) => {
if (coinCount(s.grid) === 9) return expandBoard(s, rect(5, 4)); // a row below
if (coinCount(s.grid) === 12) return expandBoard(s, rect(5, 5), { anchor: "top" });
},
}); Shrinking is refused: a locked coin has nowhere to go.
Tiers resolve through one table
A jackpot coin carries a tier, not a number, so retuning GRAND never means touching every coin, and an upgrade moves the tier rather than the value.
import { mixedCoinSet, coin, settleRespins } from "@open-rgs/holdwin";
const COINS = mixedCoinSet(
[{ item: coin(1), weight: 46 }, { item: coin(5), weight: 8 }],
{ MINI: 5, MINOR: 1.6, MAJOR: 0.35 },
);
settleRespins(state, CONFIG, JACKPOTS); Collecting a tier coin needs the table, so collector(targets, JACKPOTS) throws without it rather than absorbing a GRAND at nothing.