Pity Is Not a Higher Drop Rate: Gacha Pity and Gear Upgrades as State Machines
Adding pity to a gacha may barely move the average number of pulls, but it changes a great deal for the unluckiest players. A flat 1.6%, a hard pity at 100 pulls and a soft pity that raises the odds pull by pull all average 62 to 63 pulls, yet the unluckiest 1% need 286, 100 and 92 respectively. Once the odds depend on how many times you have already failed, the history has to become part of the state before you can work out the distribution.
In the previous post, a player who had drilled 200 times still had a 1% chance on the next try, because the geometric distribution has no memory. A pity system removes that property on purpose and makes the game remember how many times you have pulled.
Misses so far as the state
Write the chance of a hit on pull n as rate(n), where n counts pulls since the last hit. That one integer is the whole state of a pity rule, so there is nothing to simulate. Walk forward, keeping the probability of still being without a hit:
alive(1) = 1
P(first hit on pull n) = alive(n) · rate(n)
alive(n+1) = alive(n) · (1 - rate(n))
A constant rate(n) gives back the geometric distribution. A rate that becomes 1 at some pull is hard pity; one that starts climbing at some pull is what people call soft pity. With the probability for every pull in hand, the mean, median and P95 follow directly.
Checking against Genshin Impact’s published odds
Genshin Impact’s wish details give a base rate of 0.600% for a five-star character, a consolidated rate including guarantees of 1.600%, and a guarantee within 90 wishes. Soft pity appears nowhere in the official text. The community-run Genshin Impact Wiki states that “soft pity” is an unofficial term, and that its existence and starting point were worked out by players, at around wish 74 on the character banner. A common community model adds 6% per wish from wish 74. If that model is right, it should reproduce the official 1.6%. Run on Python 3.13.16:
import sys
def first_hit_dist(rate_at):
"""rate_at(n) = chance that pull n hits, given pulls 1..n-1 all missed.
Returns P(first hit happens on pull n) for n = 1..N (state = misses so far)."""
dist, alive, n = [], 1.0, 1
while alive > 1e-12:
r = min(1.0, rate_at(n))
dist.append(alive * r)
alive *= 1 - r
n += 1
return dist
def summary(dist):
mean = sum((i + 1) * p for i, p in enumerate(dist))
cdf, qs, out = 0.0, [0.5, 0.9, 0.95, 0.99], {}
for i, p in enumerate(dist):
cdf += p
while qs and cdf >= qs[0] - 1e-12:
out[qs.pop(0)] = i + 1
return mean, out, len(dist)
print("== 1. a public example: 0.6% base, guaranteed by pull 90 ==")
print(" soft pity (+6% per pull from pull 74) is a community estimate, not official")
base = 0.006
hard_only = first_hit_dist(lambda n: 1.0 if n >= 90 else base)
soft = first_hit_dist(lambda n: 1.0 if n >= 90 else base + max(0, n - 73) * 0.06)
for label, d in (("hard pity only", hard_only), ("soft + hard pity", soft)):
mean, q, worst = summary(d)
print(f"{label:17} mean {mean:5.2f} pulls -> consolidated rate {1/mean*100:.3f}%"
f" median {q[0.5]} P90 {q[0.9]} P99 {q[0.99]} worst {worst}")
== 1. a public example: 0.6% base, guaranteed by pull 90 ==
soft pity (+6% per pull from pull 74) is a community estimate, not official
hard pity only mean 69.70 pulls -> consolidated rate 1.435% median 90 P90 90 P99 90 worst 90
soft + hard pity mean 62.30 pulls -> consolidated rate 1.605% median 76 P90 80 P99 83 worst 90
A 0.6% rate with a hard guarantee at 90 gives 1.435%, which does not match the published 1.6%. Adding the community’s soft pity gives 1.605%. That makes the model consistent with the published figure, nothing more: the algorithm itself is not public, so the model still needs to be verified.
The median of 90 in the hard-pity-only row is worth a second look. At 0.6% per pull, the chance of missing 89 times in a row is about 58.5%, so more than half of all players would get their character from the guarantee. The guarantee becomes the main way people get the item, and the 0.6% base rate only lets about four in ten get there early.
Similar averages, very different tails
Back in the mining game, here are three invented rules for a rare drill head, compared with the same function:
print("\n== 2. three made-up rules for one rare drill head ==")
rules = {
"flat 1.6%, no pity": (lambda n: 0.016, False),
"1.0%, hard pity at 100": (lambda n: 1.0 if n >= 100 else 0.010, True),
"0.8%, +5%/pull from pull 82": (lambda n: 0.008 + max(0, n - 81) * 0.05, True),
}
for name, (f, capped) in rules.items():
mean, q, worst = summary(first_hit_dist(f))
worst_s = str(worst) if capped else "none"
print(f"{name:30} mean {mean:5.1f} median {q[0.5]:3d} P95 {q[0.95]:3d}"
f" P99 {q[0.99]:3d} worst {worst_s}")
== 2. three made-up rules for one rare drill head ==
flat 1.6%, no pity mean 62.5 median 43 P95 186 P99 286 worst none
1.0%, hard pity at 100 mean 63.4 median 69 P95 100 P99 100 worst 100
0.8%, +5%/pull from pull 82 mean 62.5 median 82 P95 90 P99 92 worst 101
The means are within a pull of each other and the quantiles are not. The flat 1.6% has the lowest median, with half the players done in 43 pulls, but P99 is 286 and there is no upper bound at all. The hard pity at 100 has a median of 69 and puts both P95 and P99 at 100, so the worst case is written into the rule. Soft pity has the highest median: about half the players get the item within the first 81 pulls, most of the rest are packed between 82 and 92, P95 and P99 are only two pulls apart, and nobody needs more than 101.
A notice that just says “consolidated rate about 1.6%” is true of all three, and tells players nothing about the tail. Publishing the pity rule alongside the rate fills that gap.
Upgrades that can fail backwards: an absorbing Markov chain
Gear upgrades add a complication: a failed attempt may leave the item where it was or knock it down a level. Suppose an item goes from +0 to +10, with success rates of 95%, 90%, 85% and so on down to 15%, and any failure at +5 or above drops it one level. The state is now the current level. +10 is absorbing, since once you get there you never leave, and the other levels are transient.
Absorbing Markov chains come with a ready-made formula. Put the transitions between transient states in a matrix Q; the fundamental matrix is N = (I - Q)^-1, and the expected number of steps to absorption from each state is t = N·1. You don’t need the inverse in practice, only the solution of the linear system (I - Q)·t = 1:
def solve(a, b):
"""Gaussian elimination, small dense systems only."""
n = len(a)
m = [row[:] + [b[i]] for i, row in enumerate(a)]
for c in range(n):
piv = max(range(c, n), key=lambda r: abs(m[r][c]))
m[c], m[piv] = m[piv], m[c]
for r in range(n):
if r != c and m[r][c]:
f = m[r][c] / m[c][c]
m[r] = [x - f * y for x, y in zip(m[r], m[c])]
return [m[i][n] / m[i][i] for i in range(n)]
print("\n== 3. upgrade +0 to +10 as an absorbing Markov chain ==")
SUCCESS = [0.95, 0.9, 0.85, 0.75, 0.65, 0.55, 0.45, 0.35, 0.25, 0.15] # from level i to i+1
def expected_tries(drop_from, protect=False):
"""Fail at level >= drop_from loses one level unless protected.
Solve E[i] = 1 + s*E[i+1] + (1-s)*E[fail_to], with E[10] = 0, i.e. t = (I - Q)^-1 1."""
n = len(SUCCESS)
a = [[0.0] * n for _ in range(n)]
for i, s in enumerate(SUCCESS):
a[i][i] += 1.0
if i + 1 < n:
a[i][i + 1] -= s
fail_to = i - 1 if (i >= drop_from and not protect) else i
a[i][fail_to] -= 1 - s
return solve(a, [1.0] * n)
for label, kw in (("never drops", dict(drop_from=99)),
("drops one level from +5", dict(drop_from=5)),
("drops from +5, protected", dict(drop_from=5, protect=True))):
e = expected_tries(**kw)
print(f"{label:25} expected tries +0->+10: {e[0]:7.1f} +7->+10: {e[7]:6.1f}")
scrolls = sum((1 - s) / s for s in SUCCESS[5:])
print(f"protection scrolls used on average: {scrolls:.1f}")
import random
rng = random.Random(10)
def one_player(drop_from=5):
lvl = tries = 0
while lvl < 10:
tries += 1
if rng.random() < SUCCESS[lvl]:
lvl += 1
elif lvl >= drop_from:
lvl -= 1
return tries
runs = sorted(one_player() for _ in range(20_000))
print(f"20,000 players, drops from +5: mean {sum(runs)/len(runs):.1f} median {runs[10_000]}"
f" P95 {runs[19_000]} P99 {runs[19_800]} worst {runs[-1]}")
print(f"\nPython {sys.version.split()[0]}")
== 3. upgrade +0 to +10 as an absorbing Markov chain ==
never drops expected tries +0->+10: 23.8 +7->+10: 13.5
drops one level from +5 expected tries +0->+10: 341.9 +7->+10: 326.7
drops from +5, protected expected tries +0->+10: 23.8 +7->+10: 13.5
protection scrolls used on average: 12.6
20,000 players, drops from +5: mean 340.0 median 241 P95 979 P99 1518 worst 3443
Python 3.13.16
With identical success rates, adding “drop a level on failure from +5 up” takes the expected number of attempts from 23.8 to 341.9, about fourteen times as many. Starting from +7 costs 326.7, barely less than starting from +0, because every failure at the top throws away progress. Twenty thousand simulated players average 340.0, which agrees with the formula’s 341.9; their median is 241 and their P99 is 1,518.
A protection item that prevents the drop brings it back to 23.8 attempts and uses 12.6 protection scrolls on average. That is the figure a shop needs for pricing: compare 12.6 times the scroll price with the materials for the 318 extra attempts made without protection, and you have a starting point for estimating what players will choose and pay.
Ten states is small enough for plain Gaussian elimination. With more states, say upgrade level combined with a pity counter, it is still one linear system; it just gets bigger, and a sparse solver becomes the sensible choice.
When the weights change quietly
Players never see the state or the weights, only the outcomes. On 3 January 2024 the Korea Fair Trade Commission announced a fine of 11.6 billion won against Nexon over the Cube items in MapleStory, finding under the Act on Consumer Protection in Electronic Commerce that it had solicited purchases with false information. TechRadar and KED Global report that when Cubes launched in May 2010 every option was equally likely; from September 2010 some options were drawn less often, and between August 2011 and March 2021 certain popular options had a probability of zero. In August 2011 Nexon had posted a notice saying the Cube structure had not changed. Inven Global reports that Nexon has appealed.
None of this involves RNG certification; the randomness itself may have been perfectly sound. What changed was the weight table. The fix is the same one the loot table post arrived at: generate the published odds from the production configuration and log every change, so that what players are told matches what the system does.
Resetting and keeping a pity counter
A pity counter is something the player owns, and deserves stricter handling than ordinary game state. Resetting on any hit and resetting only on the featured item produce different distributions, so the spec and the published rules both need to say which.
Whether a counter carries over when a limited banner ends affects whether players pull now or wait. Changing the carry-over rule changes something players have already accumulated.
When a client times out and resends the same pull request, the server must not charge twice or advance the counter twice. The request carries an idempotency key, and the charge, the draw and the counter update happen in one transaction, as in The Same Payment Sent Twice.
Storage matters too. A counter kept on the client can be edited by the player; one kept in a cache has to survive expiry and restarts without quietly going back to zero.
Notes
- Treat the number of misses so far as the state and you can compute a pity system’s distribution exactly, pull by pull. Rules with similar averages can still differ by more than three times at P99.
- 0.6% with hard pity at 90 gives 1.435%; adding the community’s soft pity estimate gives 1.605%, consistent with the published 1.6%. The algorithm is not public, so this does not confirm the model.
- Upgrades with downgrades are absorbing Markov chains: solve
(I - Q)·t = 1for the expected attempts. Here a downgrade rule took them from 23.8 to 341.9, which is also what a protection item’s price should be measured against. - Nexon’s fine concerned weight changes that players were not told about. Log every change to the odds and reflect it in what you publish, and define how a pity counter resets, carries over, survives retries and is stored.
Further reading
- Genshin Impact Wiki, Wish: reproduces the in-game wish details and the 90-wish guarantee, and notes that soft pity was established by player testing. A community wiki; the in-game notice is authoritative.
- Wikipedia, Absorbing Markov chain: the canonical form, the fundamental matrix
N = (I - Q)^-1and expected stepst = N1. Useful for checking the formulas. - Wikipedia, Gacha game: definitions of soft pity, hard pity and sparking, and regulatory history such as Japan’s 2012 ban on complete gacha. An encyclopaedia entry.
- Korea JoongAng Daily, Antitrust agency slaps Nexon with $8.9 million fine for misleading users: the amount and legal basis of the 3 January 2024 decision. A news report.
- TechRadar, Nexon fined almost $9 million, and KED Global, Nexon fined over MapleStory in-game item selling: the timeline of changes to the Cube odds. News reports.
- Inven Global, Nexon’s MapleStory Appeals Fine: the appeal. A news report; the court’s own announcements are authoritative on the outcome.
Ideas and technical judgement by Sheng; drafted with Claude · examples run on Python 3.13.16.