Sharing my 2nd Python model: should I keep pulling in the current banner to optimise my total paid pulls for the next banner’s character?
TLDR: I built a Python model to answer this question: “If I’ve already spent X pulls on the current banner, I’ve already gotten the current banner character, and I don’t need a dupe - should I keep pulling anyway to optimise my total paid pulls for the next banner’s character?”
This post is mostly coding and maths. If that’s not your thing, you can just read the summary below or skip the post entirely.
Summary:
My results show that if I’ve already spent X pulls to obtain the current banner character, continuing to pull on the current banner can sometimes be beneficial.
For example, when X = 47, continuing to pull starts to give a very small expected benefit. This is because once the total pulls on the current banner reach 60, I receive 10 free tickets for the next banner, which effectively reduces the number of paid pulls needed there.
As X increases, this benefit becomes larger, up until X = 59. At that point, it only takes one more pull to reach 60 and secure the 10 free tickets.
However, once X = 60, I have already earned those 10 free tickets from the pulls I previously spent. Since the reward has already been claimed, there is no longer any incentive to continue pulling on the current banner from this mechanic alone.
Hello everyone - for those who read my last post, it’s me again. This time I’m trying to answer another maths question using a slightly different scenario.
First, just to be clear: I’m definitely not the first person to run calculations or build models for this game’s pull mechanics. I’ve included links to several excellent posts in my previous post, so feel free to check those out as well.
This model is a bit more complex than the previous one because it tests every possible number of extra pulls on the current banner (0–79), and for each option it runs another Monte Carlo simulation with 100,000 trials. So it’s normal for the script to take a few minutes to run.
Disclaimer:
1. My model isn’t perfect. There may be flaws or assumptions worth challenging, so please point them out if you spot anything.
2. I’m sharing the full Python code in this community because I think it might be useful to others, whether you want to adapt it for a different gacha game or you’re learning Python. I don’t mind if you feed it into an AI to tweak or extend it.
Basics:
1. Purpose of This Model
The purpose is to determine the optimal number of extra pulls to make on the current banner before switching to the next banner, in order to minimise total expected paid pulls to the next featured 6-star.
Conceptually, the optimisation target is:
E[paid pulls]=extra pulls on current banner (0–79)+E[paid pulls on next banner until first featured 6-star]
The model simulates every possible extra pull count on the current banner and accounts for stochastic outcomes (6-star resets) to estimate the expected total paid pulls.
2. Official Endfield Rules Used or Not Used in the Model
Base Rates
- 6-star base rate: 0.8%
- PICKUP(featured 6-star) rate: 0.4% (half of total 6-star pool)
Rule 1: 30 Pull Bonus (Implemented)
- On the next banner: 30 paid pulls -> 10 free pulls
- Free 10 pulls do NOT advance pity, but can drop a 6-star
Rule 2: 60 Pull Bonus (Implemented)
- Reaching 60 pulls on the current banner -> triggers 10 free pulls on the next banner
- These advance soft/hard pity but do not count toward total expected paid pulls
Rule 3: Soft Pity (65+) (Implemented)
- No 6-star for 65 pulls -> from pull 66 onward: +5% absolute 6-star per pull
Rule 4: Small Pity (80) (Implemented)
- On the next banner: if pulls 1–79 have no 6-star -> 80th pull guaranteed 6-star
- Small pity carries over between banners
Rule 5: Big Pity (120) (Implemented)
- On the next banner: if pulls 1–119 have no featured 6-star -> 120th pull guarantees featured 6-star
- Big pity resets per banner; does not carry over
Rule 6: 240 Pull Duplication Item (Not implemented)
- 240 pulls for breakthrough item
- Reason for not using in this model: we don’t care about duplicates
3. Columns in output
Extra Now: Number of extra paid pulls made on the current banner before switching to the next banner.
Paid Next: Number of paid pulls spent on the next banner until the first featured 6-star is obtained.
Total Paid: Sum of extra paid pulls on the current banner plus paid pulls on the next banner; this is the total expected paid pulls to reach the next featured 6-star.
Free 60: Number of free pulls granted by the 60-pull bonus on the current banner that are applied to the next banner.
Free 30: Number of free pulls granted by the 30-pull bonus on the next banner. This is a calculated value from Monte Carlo simulations so it is not a straight 10.
4. Example output from the model
Here I’m only going to paste the results for X = 47 and X = 59 so you can get a sense of how the numbers change. If you want results for your own inputs, feel free to run the Python model yourself.
Pulls already spent before getting the current banner character: 47
===== CONTINUE PULLING OPTIMISER =====
Pulls already spent before getting the current banner character: 47
Optimal extra pulls in the current banner: 13
Minimum expected total paid pulls to get next banner character: 79.34
Extra Now | Paid Next | Total Paid | Free 60 | Free 30
0 | 79.81 | 79.81 | 0.00 | 8.87
1 | 79.30 | 80.30 | 0.00 | 8.86
2 | 79.15 | 81.15 | 0.00 | 8.87
3 | 78.83 | 81.83 | 0.00 | 8.87
4 | 78.33 | 82.33 | 0.00 | 8.85
5 | 78.03 | 83.03 | 0.00 | 8.87
6 | 77.58 | 83.58 | 0.00 | 8.86
7 | 77.48 | 84.48 | 0.00 | 8.88
8 | 77.13 | 85.13 | 0.00 | 8.86
9 | 77.03 | 86.03 | 0.00 | 8.87
10 | 76.41 | 86.41 | 0.00 | 8.87
11 | 76.23 | 87.23 | 0.00 | 8.87
12 | 75.90 | 87.90 | 0.00 | 8.88
13 | 66.34 | 79.34 | 10.00 | 8.54
14 | 65.85 | 79.85 | 10.00 | 8.53
15 | 65.65 | 80.65 | 10.00 | 8.52
16 | 65.04 | 81.04 | 10.00 | 8.50
17 | 64.83 | 81.83 | 10.00 | 8.50
18 | 64.62 | 82.62 | 10.00 | 8.51
19 | 64.16 | 83.16 | 10.00 | 8.51
20 | 63.92 | 83.92 | 10.00 | 8.52
21 | 63.34 | 84.34 | 10.00 | 8.52
22 | 63.19 | 85.19 | 10.00 | 8.53
23 | 62.80 | 85.80 | 10.00 | 8.51
24 | 62.36 | 86.36 | 10.00 | 8.52
25 | 60.78 | 85.78 | 10.00 | 8.51
26 | 60.22 | 86.22 | 10.00 | 8.38
27 | 59.56 | 86.56 | 10.00 | 8.11
28 | 59.43 | 87.43 | 10.00 | 7.72
29 | 59.04 | 88.04 | 10.00 | 7.30
30 | 58.88 | 88.88 | 10.00 | 6.88
31 | 58.30 | 89.30 | 10.00 | 6.49
32 | 58.28 | 90.28 | 10.00 | 6.21
33 | 57.65 | 90.65 | 10.00 | 5.96
34 | 57.40 | 91.40 | 10.00 | 5.82
35 | 56.76 | 91.76 | 10.00 | 5.70
36 | 56.39 | 92.39 | 10.00 | 5.63
37 | 56.06 | 93.06 | 10.00 | 5.61
38 | 55.56 | 93.56 | 10.00 | 5.58
39 | 55.18 | 94.18 | 10.00 | 5.57
40 | 54.83 | 94.83 | 10.00 | 5.58
41 | 54.51 | 95.51 | 10.00 | 5.58
42 | 53.92 | 95.92 | 10.00 | 5.54
43 | 53.56 | 96.56 | 10.00 | 5.55
44 | 53.02 | 97.02 | 10.00 | 5.54
45 | 52.30 | 97.30 | 10.00 | 5.49
46 | 52.08 | 98.08 | 10.00 | 5.51
47 | 51.75 | 98.75 | 10.00 | 5.49
48 | 51.08 | 99.08 | 10.00 | 5.46
49 | 50.68 | 99.68 | 10.00 | 5.46
50 | 50.32 | 100.32 | 10.00 | 5.45
51 | 49.95 | 100.95 | 10.00 | 5.44
52 | 49.49 | 101.49 | 10.00 | 5.44
53 | 48.95 | 101.95 | 10.00 | 5.43
54 | 48.54 | 102.54 | 10.00 | 5.43
55 | 47.93 | 102.93 | 10.00 | 5.40
56 | 47.54 | 103.54 | 10.00 | 5.40
57 | 47.27 | 104.27 | 10.00 | 5.39
58 | 46.62 | 104.62 | 10.00 | 5.37
59 | 46.35 | 105.35 | 10.00 | 5.36
60 | 46.16 | 106.16 | 10.00 | 5.38
61 | 45.87 | 106.87 | 10.00 | 5.37
62 | 45.55 | 107.55 | 10.00 | 5.33
63 | 45.30 | 108.30 | 10.00 | 5.33
64 | 45.11 | 109.11 | 10.00 | 5.31
65 | 45.00 | 110.00 | 10.00 | 5.31
66 | 45.59 | 111.59 | 10.00 | 5.41
67 | 47.43 | 114.43 | 10.00 | 5.63
68 | 49.75 | 117.75 | 10.00 | 5.94
69 | 52.84 | 121.84 | 10.00 | 6.33
70 | 55.52 | 125.52 | 10.00 | 6.66
71 | 58.25 | 129.25 | 10.00 | 6.99
72 | 59.79 | 131.79 | 10.00 | 7.21
73 | 61.30 | 134.30 | 10.00 | 7.42
74 | 61.99 | 135.99 | 10.00 | 7.51
75 | 62.37 | 137.37 | 10.00 | 7.59
76 | 62.93 | 138.93 | 10.00 | 7.68
77 | 62.76 | 139.76 | 10.00 | 7.68
78 | 62.75 | 140.75 | 10.00 | 7.68
79 | 62.34 | 141.34 | 10.00 | 7.67
Pulls already spent before getting the current banner character: 59
===== CONTINUE PULLING OPTIMISER =====
Pulls already spent before getting the current banner character: 59
Optimal extra pulls in the current banner: 1
Minimum expected total paid pulls to get next banner character: 70.83
Extra Now | Paid Next | Total Paid | Free 60 | Free 30
0 | 79.51 | 79.51 | 0.00 | 8.86
1 | 69.83 | 70.83 | 10.00 | 8.52
2 | 69.57 | 71.57 | 10.00 | 8.52
3 | 69.03 | 72.03 | 10.00 | 8.49
4 | 69.10 | 73.10 | 10.00 | 8.53
5 | 68.75 | 73.75 | 10.00 | 8.52
6 | 68.36 | 74.36 | 10.00 | 8.52
7 | 68.13 | 75.13 | 10.00 | 8.51
8 | 67.98 | 75.98 | 10.00 | 8.54
9 | 67.28 | 76.28 | 10.00 | 8.52
10 | 67.11 | 77.11 | 10.00 | 8.51
11 | 66.83 | 77.83 | 10.00 | 8.53
12 | 66.49 | 78.49 | 10.00 | 8.52
13 | 66.24 | 79.24 | 10.00 | 8.52
14 | 66.02 | 80.02 | 10.00 | 8.54
15 | 65.54 | 80.54 | 10.00 | 8.55
16 | 65.13 | 81.13 | 10.00 | 8.50
17 | 64.90 | 81.90 | 10.00 | 8.52
18 | 64.65 | 82.65 | 10.00 | 8.51
19 | 64.23 | 83.23 | 10.00 | 8.51
20 | 63.62 | 83.62 | 10.00 | 8.50
21 | 63.51 | 84.51 | 10.00 | 8.52
22 | 62.97 | 84.97 | 10.00 | 8.51
23 | 62.79 | 85.79 | 10.00 | 8.52
24 | 62.32 | 86.32 | 10.00 | 8.51
25 | 60.90 | 85.90 | 10.00 | 8.52
26 | 60.01 | 86.01 | 10.00 | 8.39
27 | 59.68 | 86.68 | 10.00 | 8.11
28 | 59.40 | 87.40 | 10.00 | 7.72
29 | 58.80 | 87.80 | 10.00 | 7.28
30 | 58.65 | 88.65 | 10.00 | 6.88
31 | 58.31 | 89.31 | 10.00 | 6.49
32 | 58.20 | 90.20 | 10.00 | 6.21
33 | 57.66 | 90.66 | 10.00 | 5.95
34 | 57.45 | 91.45 | 10.00 | 5.82
35 | 57.06 | 92.06 | 10.00 | 5.72
36 | 56.36 | 92.36 | 10.00 | 5.63
37 | 56.11 | 93.11 | 10.00 | 5.62
38 | 55.73 | 93.73 | 10.00 | 5.61
39 | 55.28 | 94.28 | 10.00 | 5.59
40 | 54.63 | 94.63 | 10.00 | 5.55
41 | 54.26 | 95.26 | 10.00 | 5.55
42 | 53.85 | 95.85 | 10.00 | 5.54
43 | 53.53 | 96.53 | 10.00 | 5.55
44 | 52.99 | 96.99 | 10.00 | 5.52
45 | 52.55 | 97.55 | 10.00 | 5.52
46 | 52.16 | 98.16 | 10.00 | 5.52
47 | 51.52 | 98.52 | 10.00 | 5.47
48 | 51.04 | 99.04 | 10.00 | 5.46
49 | 50.81 | 99.81 | 10.00 | 5.47
50 | 50.13 | 100.13 | 10.00 | 5.45
51 | 50.09 | 101.09 | 10.00 | 5.47
52 | 49.35 | 101.35 | 10.00 | 5.43
53 | 49.01 | 102.01 | 10.00 | 5.44
54 | 48.62 | 102.62 | 10.00 | 5.45
55 | 47.83 | 102.83 | 10.00 | 5.40
56 | 47.59 | 103.59 | 10.00 | 5.40
57 | 47.19 | 104.19 | 10.00 | 5.40
58 | 46.85 | 104.85 | 10.00 | 5.39
59 | 46.31 | 105.31 | 10.00 | 5.36
60 | 45.86 | 105.86 | 10.00 | 5.34
61 | 45.99 | 106.99 | 10.00 | 5.37
62 | 45.65 | 107.65 | 10.00 | 5.35
63 | 45.11 | 108.11 | 10.00 | 5.31
64 | 44.96 | 108.96 | 10.00 | 5.30
65 | 44.74 | 109.74 | 10.00 | 5.29
66 | 45.52 | 111.52 | 10.00 | 5.41
67 | 47.38 | 114.38 | 10.00 | 5.64
68 | 49.85 | 117.85 | 10.00 | 5.95
69 | 53.16 | 122.16 | 10.00 | 6.35
70 | 55.17 | 125.17 | 10.00 | 6.62
71 | 58.06 | 129.06 | 10.00 | 6.96
72 | 59.84 | 131.84 | 10.00 | 7.23
73 | 61.13 | 134.13 | 10.00 | 7.41
74 | 62.04 | 136.04 | 10.00 | 7.53
75 | 62.32 | 137.32 | 10.00 | 7.59
76 | 62.63 | 138.63 | 10.00 | 7.65
77 | 62.60 | 139.60 | 10.00 | 7.67
78 | 62.56 | 140.56 | 10.00 | 7.68
79 | 62.37 | 141.37 | 10.00 | 7.68
-
The Code
import random
CONFIGURATION
BASE_RATE = 0.008 PICKUP_RATE = 0.5 SOFT_START = 65 SOFT_RAMP = 0.05 HARD_PITY = 80 BIG_PITY = 120 TRIALS = 100000
SIX STAR PROBABILITY
def six_star_prob(pity): if pity >= HARD_PITY - 1: return 1.0 if pity < SOFT_START: return BASE_RATE return min(1.0, BASE_RATE + SOFT_RAMP * (pity - (SOFT_START - 1)))
CURRENT BANNER SIMULATION
def simulate_current_banner(extra_pulls, starting_pity): """ Simulate extra pulls on current banner. 6-star can reset pity. Soft pity (65+) is applied. """ pity = starting_pity for _ in range(extra_pulls): # Use soft pity curve prob = six_star_prob(pity) if random.random() < prob: # 6-star occurs -> pity resets pity = 0 else: # no 6-star -> pity increases pity += 1 return pity
NEXT BANNER SIMULATION
def simulate_next_banner(start_pity, free60): small_pity = start_pity big_pity = 0 paid_next = 0 free_60_count = free60 free_30_count = 0 pulls_done = 0 # ---- apply free60 pulls ---- for _ in range(free60): prob = six_star_prob(small_pity) hit = random.random() < prob if hit: if random.random() < PICKUP_RATE: return paid_next, free_60_count, free_30_count small_pity = 0 big_pity += 1 else: small_pity += 1 big_pity += 1 # ---- main pulling loop ---- while True: pulls_done += 1 paid_next += 1 # big pity if big_pity >= BIG_PITY - 1: return paid_next, free_60_count, free_30_count prob = six_star_prob(small_pity) hit = random.random() < prob if hit: if random.random() < PICKUP_RATE: return paid_next, free_60_count, free_30_count small_pity = 0 big_pity += 1 else: small_pity += 1 big_pity += 1 # 30 pull bonus if pulls_done == 30: free_30_count += 10 for _ in range(10): prob = six_star_prob(small_pity) hit = random.random() < prob if hit: if random.random() < PICKUP_RATE: return paid_next, free_60_count, free_30_count # free pulls reset pity but do not advance it small_pity = 0
STRATEGY SIMULATION
def simulate_strategy(pulls_spent_current, extra): # simulate current banner pity outcome start_pity = simulate_current_banner(extra, 0) # 60 pull bonus check free60 = 10 if pulls_spent_current + extra >= 60 else 0 paid_next, free60_count, free30_count = simulate_next_banner(start_pity, free60) total_paid = extra + paid_next return paid_next, total_paid, free60_count, free30_count
OPTIMISER
def find_optimal(pulls_spent_current): results = [] for extra in range(0, 80): sum_paid_next = 0 sum_total = 0 sum_free60 = 0 sum_free30 = 0 for _ in range(TRIALS): paid_next, total_paid, free60, free30 = simulate_strategy( pulls_spent_current, extra ) sum_paid_next += paid_next sum_total += total_paid sum_free60 += free60 sum_free30 += free30 results.append(( extra, sum_paid_next / TRIALS, sum_total / TRIALS, sum_free60 / TRIALS, sum_free30 / TRIALS )) best = min(results, key=lambda x: x[2]) print("\n===== CONTINUE PULLING OPTIMISER =====") print(f"Pulls already spent before getting the current banner character: {pulls_spent_current}") print(f"Optimal extra pulls in the current banner: {best[0]}") print(f"Minimum expected total paid pulls to get next banner character: {best[2]:.2f}\n") print("Extra Now | Paid Next | Total Paid | Free 60 | Free 30") for e, paid_next, total, free60, free30 in results: print(f"{e:2d} | {paid_next:9.2f} | {total:10.2f} | {free60:7.2f} | {free30:7.2f}")
MAIN
if name == "main": pulls_spent_current = int(input("Pulls already spent before getting the current banner character: ")) find_optimal(pulls_spent_current)
Posted Mar 6, 2026, 5:30 PM UTC