Odds
Randomness that feels fair: decks that deal every card once per cycle, chances that cannot streak forever, and cascades that decide who pays when the odds have to add up.
Note
ELI5. Rolling a die for a 1-in-4 chance is honest on average and cruel in person: one player sees four hits in a row, another none in twenty tries. A deck fixes the feel without faking the odds. Put one marked card in four, shuffle, draw: you cannot know which draw is the hit, but you know it comes within four, and when the deck runs out it is shuffled again. createDeck is that deck for any cards you like. createChance is the same idea reduced to one question, "did it happen", with two more models for when a deck's guarantee is too predictable. createCascade asks several of those questions in a fixed order.
These tools draw from the same cryptographic randomness as the rest of the library, but they are about fairness, not secrecy. Use them for loot tables, critical hits, round-robins with a twist, rotating canaries, anything where "random but never unlucky for long" is the requirement.
import { createCascade, createChance, createDeck } from "unsecure/odds";#The one rule: someone always pays
Over one roll the odds add up to 100%, so every percent given to a new outcome comes from somewhere. No system avoids that. What the three shapes below decide is who pays:
- A deck of equal cards: everyone pays proportionally. Add a card and every outcome shrinks.
- A deck with counts and a fill: the fill pays. Rares are fixed numbers in a fixed total; only the common bucket moves.
- A cascade: the tiers below pay. Order decides who is protected, and the fallback at the bottom pays for everyone.
Within one outcome, the chance model decides when the player experiences the rate, not what it is: a deck pays it out evenly per cycle, PRD smooths the streaks, pity stays flat then catches up.
#createDeck()
const loot = createDeck(["sword", "shield", "potion"]);
loot.draw(); // each card once per cycle, in a random order
loot.remaining; // cards left in this cycle
loot.size; // 3Items may be a list, one card per entry, or a Map of card to count, a multiset. size and fill pad either with copies of fill; they come together or not at all, and size may not be below the card count.
// shares: five cards, every probability is k/5, a sixth outcome moves them all
createDeck(["crit", "miss", "miss", "hit", "hit"]);
// counts in a fixed total: add ["rare", 3] and only "hit" shrinks, from 94 to 91
createDeck(
new Map([
["legendary", 1],
["crit", 5],
]),
{ size: 100, fill: "hit" },
);Cards are anything, including functions and other decks. Each draw is one step of a Fisher-Yates shuffle, so a cycle costs no reshuffle pass and the deck never holds more than its index order.
#Flat or nested
A real pack of cards is one shuffled pile: five hearts in a row is legal and expected. That is a flat deck of 52.
const pack = createDeck(SUITS.flatMap((suit) => RANKS.map((rank) => `${rank}${suit}`)));Nesting changes the distribution on purpose. A deck of four suit-decks deals each suit exactly once per four draws, and each suit-deck keeps its own 13-card cycle across outer cycles. That is a loot-tier table, "a guarantee at every level", not a pack of cards.
const tiers = createDeck([commonDeck, commonDeck, rareDeck]); // a rare table once per three draws
tiers.draw().draw(); // then an item from the table that came up#reshuffleAt
A plain deck makes its last cards predictable: after three misses in four cards the fourth is certain. reshuffleAt starts the next cycle while that many cards are still undrawn, the casino's cut card, so the certainty never arrives, at the price of a looser guarantee.
createDeck([true], { size: 4, fill: false, reshuffleAt: 1 });
// a hit within 3 to 7 draws, never a sure thing#createChance()
One boolean question at a fixed rate. The model is the first argument and it is required, because the three feel different:
const crit = createChance("deck", "1/4");
crit.roll(); // true exactly once per four rolls
const proc = createChance("prd", 0.25);
proc.roll(); // 25% over the long run, streaks bounded, never certain
const drop = createChance("pity", 0.006, { hard: 90, soft: 74 });
drop.roll(); // flat 0.6% for 74 rolls, climbing to certain at 90"deck"is a bag with the hits dealt in. Exact per cycle, predictable at the end of one unlessreshuffleAtis set. It takes an exact"hits/cards"string, because that string is the deck:"17/100"is a 100-card cycle, and there is no rounding rule to learn."prd", pseudo-random distribution, the Dota 2 approach, starts each attempt at a small chance and raises it after every miss, resetting on a hit. The incrementconstantis solved so the long-run rate equals the probability you asked for. Streaks are bounded, no roll is ever a certainty."pity"stays flat at thenominalchance forsoftrolls, then climbs in a straight line to a certain hit athard. Leavesoftout for pure hard pity: flat, then guaranteed. Hearthstone's legendary is{ hard: 40 }, Genshin's 5-star is{ hard: 90, soft: 74 }. Because the ramp adds hits, pity delivers more than its nominal:probabilityreports the delivered rate, computed from the curve.
PRD and pity take a number strictly between 0 and 1 or a fraction string; a deck takes the fraction only. All three expose probability, the rate actually delivered, reset(), and state.
#createCascade()
Independent chances asked in order. The first to fire names the outcome; none firing yields the trailing fallback.
const roll = createCascade(
["legendary", createChance("prd", 0.01)],
["crit", createChance("deck", "1/20")],
"hit",
);
roll.draw(); // "legendary" | "crit" | "hit"
roll.rates; // { legendary: 0.01, crit: 0.0495, hit: 0.9405 }Crit is asked only on the 99 rolls in 100 that legendary declined, so it wins 4.95% of all rolls, not 5%. That is the rule from the top applied: adding a tier dilutes only the tiers below it. rates shows the effective split so the designer can see it; to make crit win 5% overall, give it 5/99 ≈ 5.05%. Each tier is any object with roll() and probability, so every chance model fits and so does one of your own. The cascade keeps no state; the tiers do.
#State
Every deck and chance is an instance you own, one per player, per feature, per whatever needs its own luck. Nothing is shared between instances and the library keeps nothing between calls. state is a plain object to persist; pass it back as state to resume mid-cycle or mid-streak after a restart.
const deck = createDeck(CARDS);
await store.put(player, deck.state); // { order: [2, 0, 3, 1], cursor: 1 }
const resumed = createDeck(CARDS, { state: await store.get(player) });A deck snapshot holds card indices, never the cards, so a deck of functions still serializes; one that does not describe a deck of the same size is OUT_OF_RANGE. PRD and pity state is { misses }.
#Errors
| Code | When |
|---|---|
INVALID_TYPE | items is neither an array nor a Map; model is not a string; a deck probability that is not a string, any other that is neither number nor string; a cascade tier that is not [string, chance], or a fallback that is not a string |
OUT_OF_RANGE | no cards; a Map count below 1; size below the card count or without fill; reshuffleAt outside [0, size); a probability outside (0, 1); a fraction without 0 < hits < cards; hard below 1 or soft outside [0, hard]; a state that does not fit; a cascade without tiers or with a repeated outcome |
MALFORMED | a probability string that is not "hits/cards" |
UNSUPPORTED | a model other than "deck", "prd" or "pity" |
#Pitfall: card counting
In a deck, information leaks with every draw: three misses in a 4-card deck make the next hit certain. Fine for a single player's feel, exploitable in anything competitive. Reach for reshuffleAt, "prd" or "pity" when the other side can count.
#Pitfall: expecting replays
These draw from the runtime's cryptographic randomness, which cannot be seeded. A deterministic replay of a match needs the draws recorded, not the generator re-run.
#Pitfall: a wheel is not a deck
A deck remembers: what came out stays out until the cycle ends. A weighted wheel does not, every spin is independent and a thousand-spin drought is legal. Game feel wants the deck; regulated gambling and statistical simulation require the wheel. For a wheel, draw an index against cumulative weights with secureRandomNumber and keep no state.