Gacha Box

Same 50 pulls, same spend, one setting apart: a pity timer guarantees the 1%-odds ultra after a dry streak, while turning it off can leave you empty-handed.

Level: Beginner

stochasticgamerandompity-timerprobability

  • Probes: count_common, count_rare, count_ultra, spent, draws_since_top
simulation.py

Gacha box: the pity timer

Every pull costs currency and returns an item by rarity — commons are frequent, the top-tier "ultra" hides behind 1% odds. A pity timer caps the bad luck: miss the top rarity pity_after times in a row and the next pull is guaranteed to be an ultra. Same 50 pulls, same spend — with pity you walk away with a guaranteed ultra; without it you can whiff entirely.

The knob is pity_after. Watch draws_since_top: with pity it sawtooths (climbs, then snaps to zero when the guarantee fires); without it, it climbs and never resets.


import random
from tys import probe, progress


def simulate(cfg: dict):

    import simpy
    env = simpy.Environment()

    num_draws = cfg["num_draws"]            # total pulls to make
    cost_per_draw = cfg["cost_per_draw"]    # currency spent each pull
    rarities = cfg["rarities"]              # mapping of rarity -> probability
    pity_after = cfg.get("pity_after", 0)   # 0 disables the pity timer
    seed = cfg.get("seed", 12345)

    rng = random.Random(seed)

    names = list(rarities.keys())
    weights = [rarities[n] for n in names]

    counts = {n: 0 for n in names}   # cumulative draws per rarity
    spent = 0                        # total currency spent
    draws_since_top = 0              # dry streak since the last top-rarity pull
    top_rarity = names[-1]           # most coveted rarity in the pool

    done = env.event()

Record running tallies each step so the charts update live.

    def recorder():
        while True:
            for r in names:
                probe(f"count_{r}", env.now, counts[r])
            probe("spent", env.now, spent)
            probe("draws_since_top", env.now, draws_since_top)
            yield env.timeout(1)

    env.process(recorder())

Perform each draw and update state. This is where the pity logic lives.

    def dynamics():
        nonlocal spent, draws_since_top
        for i in range(num_draws):
            if pity_after and draws_since_top >= pity_after:
                rarity = top_rarity          # pity timer guarantees the top rarity
                draws_since_top = 0
            else:
                rarity = rng.choices(names, weights=weights)[0]
                draws_since_top = 0 if rarity == top_rarity else draws_since_top + 1

            counts[rarity] += 1
            spent += cost_per_draw
            progress(int(100 * (i + 1) / num_draws))
            yield env.timeout(1)

        done.succeed({"spent": spent} | {f"count_{k}": v for k, v in counts.items()})

    env.process(dynamics())
    env.run(until=done)
    return done.value


def requirements():
    return {
        "external": ["simpy==4.1.1"],
    }
With Pity.yaml
# pity.yaml — the default: the pity timer guarantees an ultra after 30 dry
# pulls, so this 50-pull session ends with exactly one ultra.
num_draws: 50
cost_per_draw: 10
rarities:
  common: 0.8
  rare: 0.19
  ultra: 0.01
pity_after: 30
seed: 0
Charts (With Pity)

count_common

Samples51 @ 0.00–50.00
Valuesmin 0.00, mean 17.80, median 18.00, max 35.00, σ 9.76

count_rare

Samples51 @ 0.00–50.00
Valuesmin 0.00, mean 6.80, median 7.00, max 14.00, σ 4.65

count_ultra

Samples51 @ 0.00–50.00
Valuesmin 0.00, mean 0.39, median 0.00, max 1.00, σ 0.49

spent

Samples51 @ 0.00–50.00
Valuesmin 0.00, mean 250.00, median 250.00, max 500.00, σ 147.20

draws_since_top

Samples51 @ 0.00–50.00
Valuesmin 0.00, mean 12.84, median 12.00, max 30.00, σ 8.30
Final Results (With Pity)
MetricValue
spent500.00
count_common35.00
count_rare14.00
count_ultra1.00
No Pity.yaml
# nopity.yaml — the contrast: identical pulls and seed, but pity_after 0
# disables the guarantee, so the 1% odds leave this session with no ultra.
num_draws: 50
cost_per_draw: 10
rarities:
  common: 0.8
  rare: 0.19
  ultra: 0.01
pity_after: 0
seed: 0
Charts (No Pity)

count_common

Samples51 @ 0.00–50.00
Valuesmin 0.00, mean 18.08, median 18.00, max 35.00, σ 10.03

count_rare

Samples51 @ 0.00–50.00
Valuesmin 0.00, mean 6.92, median 7.00, max 15.00, σ 4.78

count_ultra

Samples51 @ 0.00–50.00
Valuesmin 0.00, mean 0.00, median 0.00, max 0.00, σ 0.00

spent

Samples51 @ 0.00–50.00
Valuesmin 0.00, mean 250.00, median 250.00, max 500.00, σ 147.20

draws_since_top

Samples51 @ 0.00–50.00
Valuesmin 0.00, mean 25.00, median 25.00, max 50.00, σ 14.72
Final Results (No Pity)
MetricValue
spent500.00
count_common35.00
count_rare15.00
count_ultra0.00
FAQ
What does the pity timer do?
It bounds the worst case. Miss the top rarity pity_after times in a row and the next pull is guaranteed to be it, so draws_since_top can never climb past pity_after.
Both runs cost the same 500 currency. Why does only one get an ultra?
Only pity_after differs. With it (30) the guarantee fires on the 31st dry pull; without it (0) the 1% odds can leave you empty-handed across all 50 pulls - the same money, very different luck.
What does draws_since_top show?
The dry streak. With pity it sawtooths - climbing toward the threshold, then snapping to zero when the guarantee triggers; without pity it just climbs, since the top rarity never lands.
What is the real-world analog?
Pity timers in mobile games (and 'bad luck protection' in loot systems) turn a high-variance gamble into a bounded one: the headline drop rate stays tiny, but the worst case is capped.