Gacha Box

Simulates drawing items from a gacha box with configurable drop rates and a pity system.

Level:Beginner

stochasticgamerandom

  • Probes:count_common, count_rare, count_ultra, spent
FAQ
How does the pity mechanic work?
If the count of draws since the top rarity meets or exceeds pity_after, the next draw automatically yields the top rarity.
How are rarities chosen on a normal draw?
random.choices selects a rarity using the probability weights configured for each tier.
What metrics does the recorder process track?
It probes count_ for each rarity as well as total spent after every draw.
simulation.py

Gacha box draw simulation

In this luck-driven gacha experience, every pull costs a set amount of in-game currency and delivers an item whose rarity follows a predefined probability table—common drops appear often, while the coveted top-tier reward glitters at the end of long odds. Behind the scenes, a gentle “pity” mechanic watches your streak: if you’ve whiffed on the highest rarity for a configurable number of consecutive draws, the very next pull is guaranteed to break the drought, resetting the counter and injecting a burst of excitement. Each spin updates running tallies of total spending and how many items of each tier you’ve collected, so players can track both their fortunes and their finances over the course of a session.


import random
from tys import probe, progress


def simulate(cfg: dict):

    import simpy

    env = simpy.Environment()

Configuration

    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)  # guarantee top rarity after this many
    seed = cfg.get("seed", 12345)        # RNG seed for reproducibility

    rng = random.Random(seed)

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

State

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

    done = env.event()

Record cumulative counts and spending every step so graphs update live.

    def recorder():
        while True:
            for r in names:
                probe(f"count_{r}", env.now, counts[r])
            probe("spent", env.now, spent)
            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 applies, award the top rarity automatically

            if pity_after and draws_since_top >= pity_after:
                rarity = top_rarity
                draws_since_top = 0
            else:
                rarity = rng.choices(names, weights=weights)[0]
                if rarity == top_rarity:
                    draws_since_top = 0
                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 {
        "builtin": ["micropip", "pyyaml"],
        "external": ["simpy==4.1.1"],
    }
Default.yaml
num_draws: 50
cost_per_draw: 10
rarities:
  common: 0.8
  rare: 0.19
  ultra: 0.01
pity_after: 30
seed: 42
Charts (Default)

count_common

count_common chart
Samples51 @ 0.00–50.00
Valuesmin 0.00, mean 20.43, median 21.00, max 40.00, σ 11.06

count_rare

count_rare chart
Samples51 @ 0.00–50.00
Valuesmin 0.00, mean 4.18, median 4.00, max 9.00, σ 3.37

count_ultra

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

spent

spent chart
Samples51 @ 0.00–50.00
Valuesmin 0.00, mean 250.00, median 250.00, max 500.00, σ 147.20
Final Results (Default)
MetricValue
spent500.00
count_common40.00
count_rare9.00
count_ultra1.00