Matchmaking Analysis

In this section, we analyze the scraped battle data to answer one of the most debated questions in the Clash Royale community: Is matchmaking rigged?

The Core Question: Does the Game Counter Your Deck?

A popular theory among players is that the matchmaking algorithm intentionally pairs you against decks containing hard counters (for example, putting you against PEKKA whenever you run Mega Knight) to force a loss.

Key Finding

The data shows no evidence of rigged matchmaking.

The probability of facing any given card remains virtually identical regardless of which cards you have in your own deck.

Accounting for natural variations like player trophy range and meta shifts over time. Even across specific sub-groups, card distributions stay within expected random noise.


Statistical Methodology & Code

To prove this statistically, I compared what you actually face against the baseline expectation for your trophy level and time period.

1. Data Loading

First, we load our processed Parquet file locally. If it was not generated by the scraping code before, the function automatically falls back to fetching the big dataset directly from Hugging Face.

Set USE_COMPLETE_DATASET to True to use the 1 million matches dataset.

USE_COMPLETE_DATASET = False

from pathlib import Path
import matplotlib.pyplot as plt
import pandas as pd

def load_matches(file_name, hf_repo=None):
    local_path = Path("../data/processed") / file_name
    if local_path.exists() and not USE_COMPLETE_DATASET:
        print("Loading local file...")
        return pd.read_parquet(local_path)
    if hf_repo:
        print("Loading dataset from Hugging Face...")
        print("This might take a while and the analysis too")
        return pd.read_parquet(f"hf://datasets/{hf_repo}/{file_name}")
    raise FileNotFoundError(
        f"Could not find {local_path} and no hf_repo provided."
    )

2. Grouping Matches into “Strata” (Trophy & Time Buckets)

Card popularity changes depending on your trophy count (e.g., lower arenas play different cards than top ladder) and over time (e.g., balance updates).

If we don’t control for this, a natural shift in card popularity could look like a rigged algorithm. This function divides matches into strata-buckets based on 500-trophy ranges and specific weeks-so we only compare players against others in the exact same environment.

def assign_strata(df, trophy_bin_size=500):
    trophy_bracket = (
        df["match_average_trophies"] // trophy_bin_size * trophy_bin_size
    ).astype(int)
    time_bucket = (
        pd.to_datetime(df["timestamp"], format="%Y%m%dT%H%M%S.%fZ")
        .dt.to_period("W")
        .astype(str)
    )
    return df.assign(stratum=trophy_bracket.astype(str) + "_" + time_bucket)

3. Unpacking Decks into Card Pairs

Each match consists of 8 cards on Player 1’s side and 8 cards on Player 2’s side (64 individual card pairings per match). This function “explodes” the deck strings into individual card matchups so we can analyze how often Card A faces Card B.

def get_exploded_pairs(df):
    df = df.assign(
        p1=df["player1_deck"].str.split("|"), p2=df["player2_deck"].str.split("|")
    )
    p1_exp = df.explode("p1")[["stratum", "p1"]]
    p1_exp["match_id"] = p1_exp.index
    p2_exp = df.explode("p2")[["p2"]]
    p2_exp["match_id"] = p2_exp.index

    return pd.merge(p1_exp, p2_exp, on="match_id")

4. Calculating Statistical Deviations

This is the core calculation. For every card:

  1. We measure how frequently opponents play specific cards against it (Conditional Probability).
  2. We compare that to how often those opponent cards appear in that trophy/time bucket overall (Baseline Probability).
  3. We calculate the difference (Mean Absolute Deviation).

If the algorithm is random, the deviation between what you face and the baseline should be tiny (close to 0%).

def compute_deviations(merged_pairs, min_matches=200):
    crosstab = pd.crosstab(
        [merged_pairs["stratum"], merged_pairs["p1"]], merged_pairs["p2"]
    )

    stratum_totals = crosstab.groupby(level=0).sum()
    stratum_probs = stratum_totals.div(stratum_totals.sum(axis=1), axis=0)

    row_totals = crosstab.sum(axis=1)
    valid_rows = row_totals >= min_matches
    crosstab_valid = crosstab[valid_rows]
    row_totals_valid = row_totals[valid_rows]

    cond_probs = crosstab_valid.div(row_totals_valid, axis=0)
    baseline_aligned = stratum_probs.reindex(cond_probs.index, level=0)

    abs_deviations = (cond_probs - baseline_aligned).abs().mean(axis=1)

    weights = row_totals_valid
    weighted_dev = abs_deviations * weights

    return weighted_dev.groupby(level=1).sum() / weights.groupby(level=1).sum()

5. Visualizing the Results

This function plots a bar chart showing the average deviation score for every card in the game.

def plot_all_deviations(agg_dev):
    plot_data = agg_dev.sort_values(ascending=False)

    fig, ax = plt.subplots(figsize=(16, 6))
    ax.bar(
        plot_data.index, plot_data.values, color="steelblue", edgecolor="black"
    )

    ax.set_ylabel("Mean Absolute Deviation")
    ax.set_title(
        "Average Distribution Shift by Card (Weighted Across All Strata)"
    )
    plt.xticks(rotation=90, fontsize=8)

    plt.tight_layout()
    plt.show()

6. Main Execution Loop

We put everything together. We establish a strict threshold (1.5% maximum allowed deviation to account for natural random variance). If the overall mean deviation stays below this threshold, we conclude matchmaking is fair and random, if its not, its rigged!!

def main():
    THRESHOLD_PERCENTAGE = 1.5
    FILE_NAME = "trophy_battles.parquet"
    HF_REPO = "CrabGuyy/ClashRoyaleTrophyMatches"

    df = load_matches(FILE_NAME, hf_repo=HF_REPO)
    df = assign_strata(df)
    pairs = get_exploded_pairs(df)
    deviations = compute_deviations(pairs)

    overall_mean = deviations.mean()
    print(
        f"Overall mean absolute deviation across all cards: {overall_mean:.4%}"
    )

    if overall_mean * 100 <= THRESHOLD_PERCENTAGE:
        print(
            "This shows there is no significant deviation from the baseline distribution."
        )
        print(
            "Conclusion: The matchmaking algorithm appears random and does not pair you based on card choices."
        )
        plot_all_deviations(deviations)
    else:
        print(
            "Conclusion: The overall mean deviation exceeds the noise threshold."
        )
        print(
            "Further investigation is needed to determine if specific cards drive this shift."
        )


if __name__ == "__main__":
    main()

Conclusion

Because the calculated deviation across all cards remains below our strict 1.5% noise threshold, we can statistically reject the hypothesis that Clash Royale uses deck-based matchmaking targeting.

Keep in mind this correlation is general enough that we would expect to see an anomaly even if the game was pairing you only on a winstreak or when you are about to go up an arena, which is something some people complain about.