🐍 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.