Skip to content

Instantly share code, notes, and snippets.

@BoYanZh
Created July 2, 2025 11:48
Show Gist options
  • Select an option

  • Save BoYanZh/13dc57298ddcee6acdebf7719bda955b to your computer and use it in GitHub Desktop.

Select an option

Save BoYanZh/13dc57298ddcee6acdebf7719bda955b to your computer and use it in GitHub Desktop.
2D Simulation of Apex Legends Ranked Points
import math
import random
import matplotlib.pyplot as plt
from tqdm import tqdm
SCORE_DICT = {
20: (0, 10),
19: (0, 10),
18: (0, 10),
17: (0, 10),
16: (0, 10),
15: (5, 10),
14: (5, 10),
13: (5, 10),
12: (5, 10),
11: (5, 10),
10: (10, 12),
9: (10, 12),
8: (20, 14),
7: (20, 14),
6: (30, 16),
5: (45, 16),
4: (55, 16),
3: (70, 18),
2: (95, 22),
1: (125, 26),
}
PLACEMENT_SCORE = [SCORE_DICT[i][0] for i in range(20, 0, -1)]
KILL_SCORE = [SCORE_DICT[i][1] for i in range(20, 0, -1)]
TEAM_COUNT = 20
MY_TEAM_ID = TEAM_COUNT - 1
assert len(PLACEMENT_SCORE) == TEAM_COUNT
assert len(KILL_SCORE) == TEAM_COUNT
class Team:
id: int
pos: tuple[float, float] # pos is now a tuple of two floats
elo: float
win_count: int
placement: int
def __init__(self, id, pos, elo=2000):
self.id = id
self.pos = pos # pos should now be a tuple of two floats
self.elo = elo
self.placement = 0
self.win_count = 0
def __repr__(self) -> str:
return f"Team {self.id}, pos {self.pos[0]:.2f}, {self.pos[1]:.2f}, placement {self.placement}"
def generate_init_pos() -> tuple[float, float]:
angle = 2 * math.pi * random.random() # Random angle
radius = math.sqrt(random.random()) # Random radius
x = radius * math.cos(angle) # Convert polar to Cartesian
y = radius * math.sin(angle)
return x, y
def find_nearest(teams: list[Team], run_p) -> tuple[int, int]:
min_distance = math.inf
nearest_index = (0, 0)
for i in range(len(teams)):
for j in range(i + 1, len(teams)):
distance = math.sqrt(
(teams[i].pos[0] - teams[j].pos[0]) ** 2
+ (teams[i].pos[1] - teams[j].pos[1]) ** 2
)
if distance < min_distance:
min_distance = distance
nearest_index = (i, j)
if (
teams[nearest_index[0]].id == MY_TEAM_ID
or teams[nearest_index[1]].id == MY_TEAM_ID
):
if random.random() < run_p:
return find_nearest([x for x in teams if x.id != MY_TEAM_ID], 0)
return nearest_index
def generate_new_pos(team1, team2):
factor = random.random() # Generate a random factor between 0 and 1
new_pos = (
(1 - factor) * team1.pos[0] + factor * team2.pos[0],
(1 - factor) * team1.pos[1] + factor * team2.pos[1],
)
return new_pos
def simulate(
p=0.5, run_p=0.0, init_pos=None, active_fighter=False
) -> tuple[int, int, list[Team]]:
teams = [
Team(i, generate_init_pos()) for i in range(TEAM_COUNT)
] # pos is now a tuple of two random floats
teams[MY_TEAM_ID].elo += 400 * math.log(p / (1 - p), 10)
if init_pos is not None:
teams[MY_TEAM_ID].pos = init_pos
res: list[Team] = []
my_team_fighted = False
while len(teams) != 1:
if len(teams) == 2:
run_p = 0
ti1, ti2 = find_nearest(teams, run_p)
if teams[ti1].id == MY_TEAM_ID or teams[ti2].id == MY_TEAM_ID:
my_team_fighted = True
if active_fighter and not my_team_fighted:
if random.random() < 1 - len(teams) / TEAM_COUNT:
ti1 = next(i for i, x in enumerate(teams) if x.id == MY_TEAM_ID)
my_team_fighted = True
elo_diff = teams[ti1].elo - teams[ti2].elo
win_rate = 1 / (1 + 10 ** (-elo_diff / 400))
win = random.random() < win_rate
new_pos = generate_new_pos(teams[ti1], teams[ti2])
winner_idx = ti1 if win else ti2
loser_idx = ti2 if win else ti1
teams[winner_idx].pos = new_pos
teams[winner_idx].win_count += 1
teams[loser_idx].placement = len(teams)
res.append(teams[loser_idx])
teams.pop(loser_idx)
for team in teams:
team.pos = (
team.pos[0] + random.uniform(-0.1, 0.1),
team.pos[1] + random.uniform(-0.1, 0.1),
)
teams[0].placement = 1
res.append(teams[0])
t: Team = next((x for x in res if x.id == MY_TEAM_ID))
return t.placement, t.win_count, res
def simulate_distribution(p=0.5, run_p=0.0, init_pos=None, active_fighter=False):
print(
f"Simulating with p={p}, run_p={run_p}, "
f"init_pos={'random' if init_pos is None else init_pos}, "
f"active_fighter={active_fighter}"
)
placements = [0.0] * TEAM_COUNT
win_counts = [0.0] * TEAM_COUNT
for _ in tqdm(range(SIMULATE_COUNT)):
placement, win_count, _ = simulate(p, run_p, init_pos, active_fighter)
placements[placement - 1] += 1
win_counts[placement - 1] += win_count
win_counts = [
win_count / placements[i] if placements[i] != 0 else 0
for i, win_count in enumerate(win_counts)
]
placements = [i / SIMULATE_COUNT for i in placements]
return placements, win_counts
def markov_chain_distribution(p=0.5, n=TEAM_COUNT):
import numpy as np
P = np.zeros((n + 1, n + 1)) # placement
B = np.zeros(n + 1) # battle
for i in range(2, n + 1):
P[i, i] = 2 / i * (1 - p) # lose
P[i, i - 1] = 1 - P[i, i] # win
P[1, 1] = 1 # it can only stay at top-1
for i in range(n - 1, 0, -1):
P[i, i - 1] *= P[i + 1, i]
P[i, i] *= P[i + 1, i]
B[i] = B[i + 1] + 2 / i * p
return np.diag(P)[1:].tolist(), B[1:].tolist()
def draw(p, run_p, placement_distribution, win_count_distribution, active_fighter):
avg_placement_score, avg_kill_score = 0, 0
avg_score_min = [0] * TEAM_COUNT
for i in range(TEAM_COUNT):
avg_placement_score += (
PLACEMENT_SCORE[TEAM_COUNT - i - 1] * placement_distribution[i]
)
avg_kill_score += (
KILL_SCORE[TEAM_COUNT - i - 1]
* win_count_distribution[i]
* placement_distribution[i]
* 2.5 # 1 kill, 1 assist, 1 missed
)
avg_score_min[i] = (
PLACEMENT_SCORE[TEAM_COUNT - i - 1]
+ KILL_SCORE[TEAM_COUNT - i - 1]
* win_count_distribution[i]
* placement_distribution[i]
* 2.5
) / (TEAM_COUNT - i)
avg_time = sum([(TEAM_COUNT - i) * placement_distribution[i] for i in range(20)])
x = range(1, TEAM_COUNT + 1)
fig, ax1 = plt.subplots()
ax1.bar(x, placement_distribution)
ax1.set_xlabel("# rank")
ax1.set_ylabel("probability")
ax1.set_xticks(x)
ax2 = ax1.twinx()
ax2.plot(x, avg_score_min, "r")
ax2.set_ylabel("avg score/min")
title = (
f"win rate: {p:.2f}, "
f"run rate: {run_p:.2f}, "
f"active fighter: {active_fighter}\n"
f"avg placement score: {(avg_placement_score):.2f}, "
f"avg kill score: {(avg_kill_score):.2f}\n"
f"avg total score: {(avg_placement_score + avg_kill_score):.2f}, avg time: {avg_time:.2f}, "
f"avg score/min: {((avg_placement_score + avg_kill_score) / avg_time):.2f}"
)
plt.title(title)
print(title)
fig.tight_layout()
plt.savefig(f"img/{p:.2f}_{run_p:.2f}_{active_fighter}.png")
plt.clf()
plt.close()
def kd_to_p(kd):
return kd / (kd + 1)
def p_to_kd(p):
return round(p / (1 - p), 2)
def main():
init_pos = None
run_p = 0.0
# for active_fighter in [True, False]:
for active_fighter in [True]:
for kd in [1, 0.5, 0.75, 1.25, 1.5, 1.75, 2]:
p = kd_to_p(kd)
placement_distribution, win_count_distribution = simulate_distribution(
p,
run_p,
init_pos,
active_fighter,
)
# placement_distribution, win_count_distribution = markov_chain_distribution(p)
draw(
p,
run_p,
placement_distribution,
win_count_distribution,
active_fighter,
)
if __name__ == "__main__":
SIMULATE_COUNT = 100000
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment