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
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"],
}
# 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
| Metric | Value |
|---|---|
| spent | 500.00 |
| count_common | 35.00 |
| count_rare | 14.00 |
| count_ultra | 1.00 |
# 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
| Metric | Value |
|---|---|
| spent | 500.00 |
| count_common | 35.00 |
| count_rare | 15.00 |
| count_ultra | 0.00 |
- 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.