![]()
A comprehensive dual-rating system for competitive Pokémon battling. Each Pokémon receives separate Offensive and Defensive ratings (100-point scale), enabling strategic team building and role identification.
![]()
S-curve (logistic function) mapping for smooth strength scaling: - Inflection point: BST = 450 - Maximum: 40 points - Penalizes both very weak and recognizes elite stats
![]()
Same S-curve mapping as offensive rating (shared component)
Defensive type matchup score based on: - Resistances (×0.5, ×0.25) - Immunities (×0) - Weaknesses (×2, ×4) - Type combination synergy
# BST Score Calculator (40 points max)
calc_bst_score <- function(total, atk, spatk, eff_atk) {
L <- 40 # Maximum score
k <- 0.015 # Steepness
x0 <- 450 # Inflection point
bst <- total - atk - spatk + eff_atk
score <- L / (1 + exp(-k * (bst - x0)))
return(score)
}
# Effective Attack Power
calc_eff_atk <- function(atk, spatk){
ratio <- pmin(atk, spatk) / pmax(atk, spatk)
ifelse(ratio < 0.9, pmax(atk, spatk), 0.6*(atk + spatk))
}
# Durability Expectation
calc_eff_def <- function(hp, def, spdef){
hp * sqrt(def * spdef)
}
# Effective Speed Score
calc_eff_spd <- function(spd, vmin, vmax, v0 = 80){
B <- (spd - vmin) / (vmax - vmin)
D <- (abs(spd - v0)) / max(v0 - vmin, vmax - v0)
B + 0.5 * D
}
# Offensive Stat Distribution Score (60 points)
calc_atk_score <- function(eff_atk_s, eff_def_s, eff_spd_s){
(0.5*eff_atk_s + 0.1*eff_def_s + 0.4*eff_spd_s) * 60
}
# Defensive Stat Distribution Score (60 points total)
calc_def_score <- function(eff_atk_s, eff_def_s, defensive_score){
(0.8*eff_def_s + 0.2*eff_atk_s) * 30 + 30 * defensive_score/100
}
library(patchwork)
# BST Score curve
bst_range <- seq(200, 800, by = 5)
bst_viz <- tibble(
BST = bst_range,
Score = sapply(bst_range, function(x) calc_bst_score(x, 100, 100, 100))
)
p1 <- ggplot(bst_viz, aes(x = BST, y = Score)) +
geom_line(color = "#667eea", size = 1.5) +
geom_vline(xintercept = 450, linetype = "dashed", color = "red", alpha = 0.5) +
annotate("text", x = 450, y = 35, label = "Inflection\n(450)", color = "red", size = 3) +
labs(title = "BST Score: S-Curve Mapping",
x = "Base Stat Total", y = "Score (max 40)") +
theme_minimal()
# Effective Attack heatmap
atk_pairs <- expand.grid(atk = seq(50, 200, 10), spatk = seq(50, 200, 10))
eff_atk_viz <- atk_pairs %>%
mutate(eff_atk = calc_eff_atk(atk, spatk))
p2 <- ggplot(eff_atk_viz, aes(x = atk, y = spatk, fill = eff_atk)) +
geom_tile() +
geom_abline(slope = 1, linetype = "dashed", color = "white") +
scale_fill_viridis_c(option = "plasma") +
labs(title = "Effective Attack Power",
x = "Attack", y = "Special Attack", fill = "Eff. Atk") +
theme_minimal()
# Effective Speed curve
spd_range <- seq(5, 200, by = 5)
spd_viz <- tibble(
Speed = spd_range,
Score = calc_eff_spd(spd_range, min(spd_range), max(spd_range))
)
p3 <- ggplot(spd_viz, aes(x = Speed, y = Score)) +
geom_line(color = "#4CAF50", size = 1.5) +
geom_vline(xintercept = 80, linetype = "dashed", color = "red", alpha = 0.5) +
annotate("text", x = 80, y = 1.3, label = "Baseline\n(80)", color = "red", size = 3) +
labs(title = "Effective Speed Score",
x = "Speed Stat", y = "Normalized Score") +
theme_minimal()
p1 + p2 + p3

# Calculate defense effectiveness
defense_stats <- type_df %>%
group_by(def_type) %>%
summarise(
weaknesses = sum(multiplier == 2),
weaknesses_x4 = sum(multiplier == 4),
strong_resistances = sum(multiplier == 0.25),
resistances = sum(multiplier == 0.5),
immunities = sum(multiplier == 0),
neutral = sum(multiplier == 1),
.groups = "drop"
)
# Calculate defensive score
def_stats_scaled <- defense_stats %>%
mutate(
defensive_score = (
strong_resistances*0.2/max(strong_resistances) +
resistances*0.45/max(resistances) +
neutral*0.2/max(neutral) -
weaknesses_x4*0.1/max(weaknesses_x4) -
weaknesses*0.05/max(weaknesses)
)*100 + immunities*5
) %>%
arrange(desc(defensive_score))
# Top 15 defensive types
def_stats_scaled %>%
slice_head(n = 15) %>%
mutate(def_type = factor(def_type, levels = def_type)) %>%
pivot_longer(
cols = c(weaknesses_x4, weaknesses, neutral, strong_resistances, resistances, immunities),
names_to = "effectiveness",
values_to = "count"
) %>%
mutate(
effectiveness = factor(
effectiveness,
levels = c("weaknesses_x4", "weaknesses", "neutral",
"strong_resistances", "resistances", "immunities")
)
) %>%
ggplot(aes(x = def_type, y = count, fill = effectiveness)) +
geom_col(position = "dodge") +
scale_fill_manual(
values = c(
"weaknesses_x4" = "#B71C1C",
"weaknesses" = "#F44336",
"neutral" = "#9E9E9E",
"strong_resistances" = "#2E7D32",
"resistances" = "#4CAF50",
"immunities" = "#2196F3"
),
labels = c(
"4× Weak", "2× Weak", "Neutral",
"¼× Resist", "½× Resist", "Immune"
)
) +
labs(
title = "Top 15 Defensive Type Combinations",
subtitle = "Best defensive typings based on resistance profile",
x = "Type Combination",
y = "Count",
fill = "Effectiveness"
) +
theme_minimal(base_size = 12) +
theme(axis.text.x = element_text(angle = 45, hjust = 1))

# Type order for defensive typing
TYPE_ORDER <- c(
"Normal","Fire","Water","Electric","Grass","Ice",
"Fighting","Poison","Ground","Flying","Psychic",
"Bug","Rock","Ghost","Dragon","Dark","Steel","Fairy"
)
# Add defense type column
pokemon_df_typed <- pokemon_df %>%
mutate(
idx1 = match(type_1, TYPE_ORDER),
idx2 = match(type_2, TYPE_ORDER),
def_type = case_when(
is.na(type_2) ~ type_1,
TRUE ~ paste(
TYPE_ORDER[pmin(idx1, idx2)],
TYPE_ORDER[pmax(idx1, idx2)],
sep = "/"
)
)
) %>%
left_join(
def_stats_scaled %>% select(def_type, defensive_score),
by = "def_type"
)
# Calculate ratings
pokemon_rated <- pokemon_df_typed %>%
mutate(
# Step 1: Calculate raw effective values
eff_atk = calc_eff_atk(attack, sp_atk),
eff_def = calc_eff_def(hp, defense, sp_def),
eff_spd = calc_eff_spd(speed, min(speed), max(speed)),
# Step 2: Normalize to [0,1] scale
eff_atk_s = eff_atk / max(eff_atk),
eff_def_s = eff_def / max(eff_def),
eff_spd_s = eff_spd / max(eff_spd),
# Step 3: Calculate BST score (shared)
bst_score = calc_bst_score(total, attack, sp_atk, eff_atk),
# Step 4: Calculate final ratings
offensive_rating = bst_score + calc_atk_score(eff_atk_s, eff_def_s, eff_spd_s),
defensive_rating = bst_score + calc_def_score(eff_atk_s, eff_def_s, defensive_score),
# Tier assignment
tier = case_when(
offensive_rating >= 75 | defensive_rating >= 65 ~ "SS",
offensive_rating >= 65 | defensive_rating >= 55 ~ "S",
offensive_rating >= 55 | defensive_rating >= 45 ~ "A",
offensive_rating >= 45 | defensive_rating >= 35 ~ "B",
offensive_rating >= 35 | defensive_rating >= 25 ~ "C",
TRUE ~ "F"
),
# Role classification
role = case_when(
offensive_rating / defensive_rating >= 1.15 ~ "Offensive",
offensive_rating / defensive_rating <= 0.85 ~ "Defensive",
TRUE ~ "Balanced"
)
)
# Set factors
pokemon_rated$tier <- factor(pokemon_rated$tier, levels = c("SS", "S", "A", "B", "C", "F"))
pokemon_rated$role <- factor(pokemon_rated$role, levels = c("Offensive", "Balanced", "Defensive"))
tier_summary <- pokemon_rated %>%
count(tier) %>%
mutate(percentage = round(100 * n / sum(n), 1))
kable(tier_summary, caption = "Pokémon Distribution by Tier", align = "lcc")
| tier | n | percentage |
|---|---|---|
| SS | 14 | 1.9 |
| S | 66 | 8.9 |
| A | 147 | 19.8 |
| B | 238 | 32.1 |
| C | 227 | 30.6 |
| F | 50 | 6.7 |
ggplot(tier_summary, aes(x = tier, y = n, fill = tier)) +
geom_col(show.legend = FALSE) +
geom_text(aes(label = paste0(n, "\n(", percentage, "%)")),
vjust = -0.5, size = 4) +
scale_fill_manual(values = c("SS" = "#FF3CAC", "S" = "#FFD700",
"A" = "#9C27B0", "B" = "#2196F3",
"C" = "#4CAF50", "F" = "#9E9E9E")) +
labs(title = "Tier Distribution",
x = "Tier", y = "Count") +
theme_minimal(base_size = 12)

role_summary <- pokemon_rated %>%
count(role) %>%
mutate(percentage = round(100 * n / sum(n), 1))
kable(role_summary, caption = "Pokémon Distribution by Battle Role", align = "lcc")
| role | n | percentage |
|---|---|---|
| Offensive | 369 | 49.7 |
| Balanced | 367 | 49.5 |
| Defensive | 6 | 0.8 |
ggplot(pokemon_rated, aes(x = offensive_rating, y = defensive_rating)) +
geom_point(aes(color = tier, shape = role), alpha = 0.6, size = 3) +
geom_abline(slope = 1, linetype = "dashed", color = "gray50", alpha = 0.5) +
geom_smooth(method = "lm", se = FALSE, linetype = "dashed", color = "gray30") +
scale_color_manual(values = c("SS" = "#FF3CAC", "S" = "#FFD700",
"A" = "#9C27B0", "B" = "#2196F3",
"C" = "#4CAF50", "F" = "#9E9E9E")) +
labs(
title = "Offensive vs Defensive Rating Distribution",
subtitle = "Points above diagonal = defense-oriented, below = offense-oriented",
x = "Offensive Rating", y = "Defensive Rating",
color = "Tier", shape = "Role"
) +
theme_minimal(base_size = 12)

top_offensive <- pokemon_rated %>%
arrange(desc(offensive_rating)) %>%
select(name, type_1, type_2, role, offensive_rating, defensive_rating, tier)
top_offensive |>
head(20) |>
kable(caption = "Top 20 Offensive Pokémon",
digits = 1,
align = "lllcccc")
| name | type_1 | type_2 | role | offensive_rating | defensive_rating | tier |
|---|---|---|---|---|---|---|
| Mega Mewtwo Y | Psychic | NA | Offensive | 82.0 | 59.9 | SS |
| Mega Rayquaza | Dragon | Flying | Offensive | 81.8 | 63.5 | SS |
| Ultra Necrozma | Psychic | Dragon | Offensive | 80.8 | 61.5 | SS |
| Mega Mewtwo X | Psychic | Fighting | Offensive | 80.1 | 60.5 | SS |
| Shadow Rider Calyrex | Psychic | Ghost | Offensive | 77.7 | 57.0 | SS |
| Zacian Crowned Sword | Fairy | Steel | Balanced | 76.9 | 68.7 | SS |
| Mega Alakazam | Psychic | NA | Offensive | 74.7 | 49.2 | S |
| Eternatus | Poison | Dragon | Balanced | 73.6 | 65.3 | SS |
| Mega Diancie | Rock | Fairy | Offensive | 73.2 | 54.6 | S |
| Arceus | Normal | NA | Offensive | 73.1 | 63.5 | S |
| Primal Kyogre | Water | NA | Balanced | 72.9 | 64.0 | S |
| Primal Groudon | Ground | Fire | Balanced | 72.9 | 64.8 | S |
| Mewtwo | Psychic | NA | Offensive | 71.8 | 55.4 | S |
| Koraidon | Fighting | Dragon | Offensive | 71.2 | 59.5 | S |
| Miraidon | Electric | Dragon | Offensive | 71.2 | 60.3 | S |
| Palkia Origin Forme | Water | Dragon | Offensive | 70.5 | 57.6 | S |
| Zamazenta Crowned Shield | Fighting | Steel | Balanced | 70.4 | 67.4 | SS |
| Regigigas | Normal | NA | Offensive | 70.1 | 59.9 | S |
| White Kyurem | Dragon | Ice | Offensive | 70.1 | 58.8 | S |
| Black Kyurem | Dragon | Ice | Offensive | 70.1 | 58.8 | S |
top_defensive <- pokemon_rated %>%
arrange(desc(defensive_rating)) %>%
select(name, type_1, type_2, role, defensive_rating, offensive_rating, tier)
top_defensive |>
head(20) |>
kable(caption = "Top 20 Defensive Pokémon",
digits = 1,
align = "lllcccc")
| name | type_1 | type_2 | role | defensive_rating | offensive_rating | tier |
|---|---|---|---|---|---|---|
| Zygarde Complete Forme | Dragon | Ground | Balanced | 72.5 | 66.4 | SS |
| Giratina Altered Forme | Ghost | Dragon | Balanced | 70.1 | 65.2 | SS |
| Zacian Crowned Sword | Fairy | Steel | Balanced | 68.7 | 76.9 | SS |
| Zamazenta Crowned Shield | Fighting | Steel | Balanced | 67.4 | 70.4 | SS |
| Dialga Origin Forme | Steel | Dragon | Balanced | 66.9 | 66.6 | SS |
| Giratina Origin Forme | Ghost | Dragon | Balanced | 66.7 | 66.8 | SS |
| Solgaleo | Psychic | Steel | Balanced | 65.9 | 65.3 | SS |
| Eternatus | Poison | Dragon | Balanced | 65.3 | 73.6 | SS |
| Terapagos Stellar Form | Normal | NA | Balanced | 65.1 | 65.4 | SS |
| Primal Groudon | Ground | Fire | Balanced | 64.8 | 72.9 | S |
| Mega Metagross | Steel | Psychic | Balanced | 64.7 | 69.3 | S |
| Dusk Mane Necrozma | Psychic | Steel | Balanced | 64.4 | 65.0 | S |
| Dialga | Steel | Dragon | Balanced | 64.4 | 64.9 | S |
| Primal Kyogre | Water | NA | Balanced | 64.0 | 72.9 | S |
| Mega Aggron | Steel | NA | Balanced | 63.9 | 61.9 | S |
| Mega Rayquaza | Dragon | Flying | Offensive | 63.5 | 81.8 | SS |
| Arceus | Normal | NA | Offensive | 63.5 | 73.1 | S |
| Mega Tyranitar | Rock | Dark | Balanced | 63.2 | 68.7 | S |
| Melmetal | Steel | NA | Balanced | 62.9 | 58.3 | S |
| Lugia | Psychic | Flying | Balanced | 62.8 | 66.1 | S |
top_regular <- pokemon_rated %>%
filter(category == "Regular") %>%
arrange(desc(pmax(defensive_rating+10, offensive_rating))) %>%
select(name, type_1, type_2, role, offensive_rating, defensive_rating, tier)
top_regular |>
head(20) |>
kable(caption = "Top 20 Regular Pokémon (Accessible for Beginners)",
digits = 1,
align = "lllcccc")
| name | type_1 | type_2 | role | offensive_rating | defensive_rating | tier |
|---|---|---|---|---|---|---|
| Mega Alakazam | Psychic | NA | Offensive | 74.7 | 49.2 | S |
| Mega Metagross | Steel | Psychic | Balanced | 69.3 | 64.7 | S |
| Mega Aggron | Steel | NA | Balanced | 61.9 | 63.9 | S |
| Mega Tyranitar | Rock | Dark | Balanced | 68.7 | 63.2 | S |
| Mega Steelix | Steel | Ground | Balanced | 58.8 | 62.4 | S |
| Mega Gyarados | Water | Dark | Balanced | 65.1 | 61.1 | S |
| Mega Salamence | Dragon | Flying | Offensive | 69.9 | 58.7 | S |
| Mega Gengar | Ghost | Poison | Offensive | 69.8 | 51.6 | S |
| Ash-Greninja | Water | Dark | Offensive | 69.7 | 50.6 | S |
| Mega Garchomp | Dragon | Ground | Offensive | 69.5 | 59.2 | S |
| Mega Aerodactyl | Rock | Flying | Offensive | 69.4 | 53.2 | S |
| Slaking | Normal | NA | Offensive | 69.2 | 58.9 | S |
| Mega Dragonite | Dragon | Flying | Balanced | 66.8 | 59.0 | S |
| Mega Gallade | Psychic | Fighting | Offensive | 68.3 | 51.8 | S |
| Hisuian Goodra | Steel | Dragon | Balanced | 55.0 | 58.1 | S |
| Mega Scizor | Bug | Steel | Balanced | 60.3 | 57.7 | S |
| Mega Heracross | Bug | Fighting | Offensive | 67.6 | 55.8 | S |
| Mega Swampert | Water | Ground | Balanced | 61.4 | 57.2 | S |
| Mega Sceptile | Grass | Dragon | Offensive | 67.0 | 44.8 | S |
| Galarian Darmanitan Zen Mode | Ice | Fire | Offensive | 66.5 | 45.7 | S |
# Export Offensive Pokemon Ranking list
write_csv(top_offensive, "data/top_offensive.csv")
# Export Defensive Pokemon Ranking list
write_csv(top_defensive, "data/top_defensive.csv")
# Export Regular Pokemon Ranking list
write_csv(top_regular, "data/top_regular.csv")
![]()
Offensive: Ground, Fighting, Fire (high super-effective coverage) Defensive: Steel/Fairy, Steel/Flying, Water/Ground (few weaknesses, many resistances)
Rating system designed for competitive singles format • Based on final evolution forms only