Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

How I built a Pokémon pack opening simulator with real, measured odds

A lot of Pokémon pack simulators online don't say where their odds come from. Open a few packs and you can pull three gold cards in a row, which is fun for a minute and useless if you want to know what a real booster box

A lot of Pokémon pack simulators online don't say where their odds come from. Open a few packs and you can pull three gold cards in a row, which is fun for a minute and useless if you want to know what a real booster box gives you.

So I built RipMath, a free pack simulator and card price site, with one rule: the odds should come from real opened packs wherever possible, and the site should say clearly when they don't. Here's how it works under the hood.

The pack is a list of slots

A modern English Pokémon booster pack has 10 cards: 4 commons, 3 uncommons, 2 reverse holos and 1 "rare slot" that can be anything from a plain rare to a gold Hyper Rare. Older packs look different. A 1999 Base Set pack had 11 cards: 7 commons, 3 uncommons and 1 rare, with no reverse holos.

So a pack is described as a template per era:

export function packTemplate(set) {
  const year = Number(String(set.releaseDate).slice(0, 4));
  if (/^(Base|Gym|Neo)$/i.test(set.series) || year <= 2001) {
    return { commonSlots: 7, uncommonSlots: 3, reverseHoloSlots: 0 };
  }
  if (/^(Scarlet & Violet|Mega Evolution)$/i.test(set.series) || year >= 2023) {
    return { commonSlots: 4, uncommonSlots: 3, reverseHoloSlots: 2 };
  }
  return { commonSlots: 5, uncommonSlots: 3, reverseHoloSlots: 1 };
}

Commons, uncommons and reverse holos are picked uniformly from their pools. All the interesting maths lives in the rare slot.

The rare slot is a weighted draw

The rare slot is a weighted random pick over rarities, then a uniform pick of a card within that rarity:

function weightedRarity(weights, rng) {
  const entries = Object.entries(weights);
  const total = entries.reduce((t, [, w]) => t + w, 0);
  let r = rng() * total;
  for (const [rarity, w] of entries) {
    r -= w;
    if (r < 0) return rarity;
  }
  return entries[entries.length - 1][0];
}

The weights don't need to add up to anything. They can be raw counts from a sample of opened packs ("we saw 640 rares, 190 Double Rares, 90 Illustration Rares...") and the draw normalises them on the fly.

Where the odds come from

The Pokémon Company doesn't publish pull rates, so there are two kinds of numbers on the site:

  1. Measured. For 28 sets there's a file with counts from a real sample of opened packs. Those counts become the rare-slot weights directly, and the set page prints the sample size next to every rate.
  2. Estimated. For everything else the simulator falls back to default weights per rarity, and the UI labels the odds as estimated.

The second group needed one more rule. Some old rarities (Gold Stars, LV.X, Prime cards) are so rare and so badly documented that any weight I pick is basically a guess. Those are flagged in code, and the site never shows an expected value that depends on them:

// Rarities whose weight is only a guess: never used for expected-value numbers
// without a measured sample.
export const ROUGH_ESTIMATES = new Set(['Rare Holo Star', 'Rare Holo LV.X', 'Rare Prime', 'LEGEND' /* ... */]);

This matters more than it sounds. "We don't know yet" is a better answer than a confident wrong number.

Expected value without simulating

Once a pack is a set of slots with known probabilities, you don't need to open a million virtual packs to know what one is worth on average. Expected value is just a sum: for each slot, the probability of each outcome times the average price of the cards in that outcome.

export function packEV(config, price) {
  const avg = (list) => list.length
    ? list.reduce((s, c) => s + (price(c) || 0), 0) / list.length
    : 0;
  let ev = config.commonSlots * avg(config.commons)
         + config.uncommonSlots * avg(config.uncommons)
         + config.reverseHoloSlots * avg(config.reversePool);
  for (const [rarity, p] of Object.entries(rareSlotOdds(config))) {
    ev += p * avg(config.byRarity.get(rarity) || []);
  }
  return ev;
}

That's exact and instant, which is nice because it runs for every set on every build. The results are a bit sobering. For the recent sets with measured odds (Pitch Black, Chaos Rising, Perfect Order), the cards in an average pack were worth roughly 53 to 61% of what a pack costs when you buy a whole booster box, as of early November 2026.

When you do want randomness: seeded boxes

The average hides how swingy a box is. Two people can open the same box and one gets $40 of cards while the other hits a $400 chase card. To show that spread, the build simulates 1,000 boxes per set and looks at the distribution.

The catch is that a static site gets rebuilt every day, and I didn't want the "chance your box beats its price" number to jump around between builds just because Math.random() rolled differently. So the build uses a tiny seeded PRNG (mulberry32) instead:

export function mulberry32(seed) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

Same seed, same 1,000 boxes, every build. The numbers only change when prices or odds change, which is exactly what you want.

One file, two runtimes

The simulator core is a set of pure functions with no DOM access. The same file runs in the browser when someone clicks "Open 10 packs" and in Node during the build, where it calculates expected values and box distributions for every set. That avoids a whole class of bugs where the site says one thing and the simulator does another.

The boring part that makes it work

  • Static pages. One Node build script generates a page per set and per card, about 30,000 cards across English and Japanese sets.
  • Daily rebuild. A scheduled GitHub Action pulls fresh market prices, saves a daily price snapshot (so every card gets a price history chart) and rebuilds the site.
  • Hosting. Cloudflare Pages serves it all as static files, so a traffic spike on release day costs nothing.

What I'd tell anyone building something similar

Label your assumptions in the UI, not just in the code. In a hobby where people spend real money chasing odds, "measured from 3,500 packs" and "estimated" are very different statements, and users deserve to know which one they're looking at.

If you want to see it in action, the newest set, Delta Reign, has a simulator on its page.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.