Overview

Charizard Garchomp Blissey Aggron Tyranitar Metagross Lucario Scizor

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.

100
Offensive Rating Scale
100
Defensive Rating Scale
6
Tier Levels (SS to F)
3
Battle Roles

Rating System Components

Offensive Rating (100 Points)

Lucario

BST Score (40 points)

S-curve (logistic function) mapping for smooth strength scaling: - Inflection point: BST = 450 - Maximum: 40 points - Penalizes both very weak and recognizes elite stats

Offensive Stats Distribution (60 points)

  • Effective Attack Power (50%): Recognizes mixed vs specialized attackers
    • Mixed attackers (ratio ≥ 0.9): weighted average of Attack and Sp. Attack
    • Specialized attackers: use higher of Attack or Sp. Attack
  • Effective Speed Score (40%): Dual tactical value
    • Fast sweepers (high speed) AND
    • Trick Room users (very low speed) both valued
  • Durability Buffer (10%): Minimum survivability requirement

Defensive Rating (100 Points)

Shuckle

BST Score (40 points)

Same S-curve mapping as offensive rating (shared component)

Defensive Stats Distribution (30 points)

  • Durability Expectation (80%): HP × √(Def × SpDef)
    • Uses geometric mean to penalize unbalanced defenses
    • Rewards balanced defensive investment
  • Counter-Attack Ability (20%): Minimum offensive threat
    • Ensures defensive Pokémon aren’t complete walls

Type Advantage (30 points)

Defensive type matchup score based on: - Resistances (×0.5, ×0.25) - Immunities (×0) - Weaknesses (×2, ×4) - Type combination synergy


Rating Formulas

# 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
}

Visualization of Scoring Functions

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


Type Effectiveness Analysis

Defensive Type Rankings

# 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))


Calculate Pokémon Ratings

# 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"))

Rating Distribution

Tier Distribution

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")
Pokémon Distribution by Tier
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 Distribution

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")
Pokémon Distribution by Battle Role
role n percentage
Offensive 369 49.7
Balanced 367 49.5
Defensive 6 0.8

Offensive vs Defensive Ratings

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)


Rating Result

Mega Mewtwo Y
“The Strongest Sword”
Mega Mewtwo Y
Zygarde Complete Forme
“The Strongest Shield”
Zygarde Complete Forme
Mega Alakazam
“The strongest ordinary Pokémon”
Mega Alakazam

Top 20 Offensive Pokémon

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")
Top 20 Offensive Pokémon
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 20 Defensive Pokémon

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")
Top 20 Defensive Pokémon
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

Best Regular Pokémon (For Beginners)

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")
Top 20 Regular Pokémon (Accessible for Beginners)
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")

Summary

Pikachu Eevee Snorlax Arcanine Lapras Gyarados Umbreon Sylveon

Dual Rating System Benefits

  1. Separate Offensive & Defensive Evaluation: No longer forced to choose between attacking power and tanking ability
  2. Type Synergy Recognition: Defensive typing contributes 30% to defensive rating
  3. Mixed Attacker Support: Algorithm recognizes both specialized and versatile attackers
  4. Speed Flexibility: Values both fast sweepers and Trick Room users
  5. Balanced Defense Rewards: Geometric mean prevents glass cannon defenses

Key Statistics

  • Average Offensive Rating: 45.1/100
  • Average Defensive Rating: 39.3/100
  • SS Tier Pokémon: 14 (1.9%)
  • Most Common Role: Offensive (49.7%)

Best Type Combinations

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