🐍 Practical Modelling
Why model probabilities in games?
Probability theory provides formulas but in practice random processes can behave in unexpected ways. Simulation in Python allows you to:
- Verify theoretical calculations.
- Estimate rare events.
- Compare different gameplay strategies.
- Visualize the distribution of random variables.
Simulation of rolling two dice
Testing the law of large numbers by rolling the dice 10,000 times.
import random
import matplotlib.pyplot as plt
num_rolls = 10000
results = {i: 0 for i in range(2, 13)}
for _ in range(num_rolls):
die1 = random.randint(1, 6)
die2 = random.randint(1, 6)
total = die1 + die2
results[total] += 1
frequencies = {s: results[s] / num_rolls for s in results}
theoretical = {2: 1/36, 3: 2/36, 4: 3/36, 5: 4/36, 6: 5/36, 7: 6/36,
8: 5/36, 9: 4/36, 10: 3/36, 11: 2/36, 12: 1/36}
print("Sum | Experiment | Theory")
for s in range(2, 13):
print(f"{s:4d} | {frequencies[s]:7.4f} | {theoretical[s]:.4f}")
# Comparison chart
plt.bar(range(2,13), [frequencies[s] for s in range(2,13)], alpha=0.6, label='Experiment')
plt.plot(range(2,13), [theoretical[s] for s in range(2,13)], 'ro-', label='Theory')
plt.xlabel('Sum'); plt.ylabel('Rate')
plt.legend(); plt.show()
Frequency 7 ≈ 16.67%. Deviations decrease as the number of rolls increases. The greater the number of iterations, the closer the experimental frequencies get to the theoretical ones.
Modelling critical hits
Suppose a character has a 20% critical hit chance and a damage multiplier of ×2. Through simulation, one can estimate how often a sequence of 10 hits will result in an average damage output that is lower than expected.
import random
def simulate_crits(n_attacks=10000, crit_chance=0.2, crit_mult=2, base_damage=100):
total_damage = 0
crits = 0
for _ in range(n_attacks):
if random.random() < crit_chance:
total_damage += base_damage * crit_mult
crits += 1
else:
total_damage += base_damage
avg_damage = total_damage / n_attacks
print(f"Crits: {crits}/{n_attacks} ({crits/n_attacks:.1%})")
print(f"Avg. damage: {avg_damage:.2f} (theory: {base_damage * (1 + crit_chance*(crit_mult-1)):.2f})")
simulate_crits()
The result is typically close to an average damage of 120.
Chances of a legendary item dropping
In gacha-games, a common question is: "How many attempts are needed to obtain an item with a 1% drop rate?" Simulation provides a distribution for the number of attempts required.
import random
import matplotlib.pyplot as plt
def until_success(p=0.01):
attempts = 0
while True:
attempts += 1
if random.random() < p:
return attempts
# Simulating 10,000 players
results = [until_success(0.01) for _ in range(10000)]
print(f"Avg. number of rolls: {sum(results)/len(results):.1f} (theory: 100)")
print(f"Mean: {sorted(results)[len(results)//2]}")
print(f"Max: {max(results)}")
plt.hist(results, bins=50, density=True, alpha=0.7)
plt.xlabel('Number of rolls')
plt.ylabel('% of players')
plt.title('Distribution of rolls before the drop')
plt.show()
Although the average is 100, many players will obtain the item sooner while some will take more than 300 attempts.
Law of large numbers in practice
The more trials there are, the closer the sample mean gets to the expected value. The following demonstrates how the average damage converges as the number of hits increases.
import random
import matplotlib.pyplot as plt
cumulative_avg = []
total = 0
for i in range(1, 5001):
damage = 100 if random.random() < 0.8 else 200
total += damage
cumulative_avg.append(total / i)
plt.plot(cumulative_avg, label='Mov average')
plt.axhline(y=120, color='r', linestyle='--', label='Theor EV=120')
plt.xlabel('Number of hits')
plt.ylabel('Avh damage')
plt.legend()
plt.show()
The graph shows how random fluctuations gradually die down and the average stabilizes around 120.
Simulating strategies in "Rock, Paper, Scissors"
One can test how effective a random strategy is against a predictable opponent.
import random
def play(strategy1, strategy2, rounds=1000):
wins1 = 0
moves = ['rock', 'paper', 'scissors']
for _ in range(rounds):
m1 = strategy1()
m2 = strategy2()
if (m1 == 'rock' and m2 == 'scissors') or \
(m1 == 'scissors' and m2 == 'paper') or \
(m1 == 'paper' and m2 == 'rock'):
wins1 += 1
return wins1 / rounds
def random_strategy():
return random.choice(['rock', 'paper', 'scissors'])
def always_rock():
return 'rock'
print(f"Random vs. always_rock: {play(random_strategy, always_rock):.2%} wins")
print(f"Random vs. random: {play(random_strategy, random_strategy):.2%} wins")
A random strategy wins about 33% of the time against an "always rock" strategy while against another random strategy, it results in approximately 33% wins, 33% losses and 33% draws.
Conclusion
Modelling in Python is a powerful tool for game design and analysis. It helps:
- Check the balance of probabilities before implementing them in the game code.
- Detect rare but critical scenarios.
- Develop an intuitive understanding of randomness.
All the examples provided can be run in any Python environment that has the `random` and `matplotlib` libraries installed.