MakeoverMonday: Mario Game Sales - Nintendo’s Compounding Franchise Engine

MakeoverMonday
Python
Gaming
Business
Franchise lifecycle, platform transitions, and hit concentration across 40 years of Mario titles
Author

chokotto

Published

March 16, 2026

Overview

This week’s MakeoverMonday explores Mario game sales across four decades of Nintendo history. Rather than a simple ranking, we treat the franchise like an asset portfolio: examining hit concentration, platform lifecycle, and regional market share to understand how Nintendo’s flagship IP compounds value over time.

  • Data Source: VGChartz / vgsales dataset
  • Scope: All Mario-branded Nintendo titles in the dataset (1983–2016)
  • Angle: franchise durability, platform transitions, regional decomposition

Data

Show code
import pandas as pd
import numpy as np
import plotly.graph_objects as go
import plotly.express as px
from plotly.subplots import make_subplots
Show code
url = "https://raw.githubusercontent.com/cajjster/data_files/main/vgsales.csv"
vgsales = pd.read_csv(url)
vgsales.columns = vgsales.columns.str.replace(" ", "_")

mario = vgsales[
    vgsales["Name"].str.contains("Mario", case=False, na=False)
    & (vgsales["Publisher"] == "Nintendo")
].copy()

mario["Year"] = pd.to_numeric(mario["Year"], errors="coerce")
mario = mario.dropna(subset=["Year"])
mario["Year"] = mario["Year"].astype(int)

print(f"Mario titles: {mario['Name'].nunique()}")
print(f"Total records: {len(mario)}")
print(f"Year range: {mario['Year'].min()} - {mario['Year'].max()}")
print(f"Global sales: {mario['Global_Sales'].sum():.1f}M units")
Mario titles: 93
Total records: 104
Year range: 1983 - 2016
Global sales: 527.3M units

My Makeover

What I Changed

The original chart has been redesigned with a “conclusion first” hierarchy:

  1. Hero — Hit Concentration: one chart that immediately shows where the money is
  2. Decomposition: regional mix and genre diversity side by side
  3. Context: how platform cycles drive the sales pattern over time

Key design decisions: insight-driven titles, grey + single highlight colour, direct labels instead of legends.

Hit Concentration: A Few Blockbusters Carry the Franchise

Show code
title_by_global = (
    mario.groupby("Name", as_index=False)["Global_Sales"]
    .sum()
)
top10 = (
    title_by_global
    .nlargest(10, "Global_Sales")
    .sort_values("Global_Sales")
    .reset_index(drop=True)
)

total_sales = title_by_global["Global_Sales"].sum()
top5_sales  = title_by_global.nlargest(5, "Global_Sales")["Global_Sales"].sum()
top5_pct    = top5_sales / total_sales * 100

colors = [
    "#e63946" if i >= len(top10) - 3 else "#94a3b8"
    for i in range(len(top10))
]

fig = go.Figure()
fig.add_trace(
    go.Bar(
        y=top10["Name"],
        x=top10["Global_Sales"],
        orientation="h",
        marker_color=colors,
        text=top10["Global_Sales"].round(1),
        texttemplate="%{text}M",
        textposition="outside",
        cliponaxis=False,
        hovertemplate="%{y}<br>%{x:.1f}M units<extra></extra>",
    )
)

fig.update_layout(
    **THEME,
    title=make_title(
        f"Mario's blockbusters dominate: "
        f"top 5 titles = <b>{top5_pct:.0f}%</b> of franchise sales"
    ),
    xaxis=dict(title="Sales (M units)", showgrid=True, gridcolor="#e2e8f0", zeroline=False),
    yaxis=dict(title=""),
    showlegend=False,
    height=500,
    margin=dict(t=100, b=140, l=220, r=100),
)
fig.add_annotation(
    text="Top 10 titles by global sales (M units) · 1983–2016  ·  <span style='color:#e63946'>■</span> Top 3",
    xref="paper", yref="paper",
    x=0, y=1.06,
    showarrow=False,
    font=dict(size=11, color="#64748b"),
    align="left",
)
add_source(fig, y=-0.22)
assert_no_title_overlap(fig)
fig.show()
fig.write_image("chart-1.png", width=1200, height=600, scale=2)

Regional Mix: North America Leads

Show code
# --- データ準備 ---
total = mario[["NA_Sales","EU_Sales","JP_Sales","Other_Sales"]].sum()
regions = pd.DataFrame({
    "Region":  ["North America", "Europe", "Japan", "Other"],
    "Sales":   [total["NA_Sales"], total["EU_Sales"], total["JP_Sales"], total["Other_Sales"]],
    "Color":   ["#3b82f6", "#10b981", "#f59e0b", "#94a3b8"],
})
regions["Pct"] = regions["Sales"] / regions["Sales"].sum() * 100
na_pct = regions.loc[regions["Region"] == "North America", "Pct"].values[0]

fig_b = go.Figure()
for _, row in regions.iterrows():
    pct = row["Pct"]
    label_text = f"{row['Region']}<br>{pct:.0f}%" if pct >= 8 else ""
    fig_b.add_trace(
        go.Bar(
            x=[pct], y=[""],
            orientation="h",
            marker_color=row["Color"],
            name=row["Region"],
            text=[label_text],
            textposition="inside",
            insidetextanchor="middle",
            textfont=dict(size=12, color="white"),
            hovertemplate=f"{row['Region']}: {pct:.1f}%<extra></extra>",
        )
    )

fig_b.update_layout(
    **THEME,
    title=make_title(
        f"North America drives ~<b>{na_pct:.0f}%</b> of global Mario revenue"
    ),
    barmode="stack",
    showlegend=False,
    height=220,
    margin=dict(t=60, b=80, l=40, r=40),
    xaxis=dict(showticklabels=False, showgrid=False, zeroline=False),
    yaxis=dict(showticklabels=False),
)
add_source(fig_b, y=-0.35)
assert_no_title_overlap(fig_b)
fig_b.show()

Genre Breakdown: Platform Dominates

Show code
genre_sales = (
    mario.groupby("Genre", as_index=False)["Global_Sales"]
    .sum()
    .sort_values("Global_Sales", ascending=True)
    .reset_index(drop=True)
)
top_genre = genre_sales.iloc[-1]["Genre"]
genre_colors = [
    "#e63946" if g == top_genre else "#94a3b8"
    for g in genre_sales["Genre"]
]

fig_c = go.Figure()
fig_c.add_trace(
    go.Bar(
        x=genre_sales["Global_Sales"],
        y=genre_sales["Genre"],
        orientation="h",
        marker_color=genre_colors,
        text=genre_sales["Global_Sales"].round(1),
        texttemplate="%{text}M",
        textposition="outside",
        cliponaxis=False,
        hovertemplate="%{y}: %{x:.1f}M<extra></extra>",
        showlegend=False,
    )
)

fig_c.update_layout(
    **THEME,
    title=make_title(
        "Platform games lead, but racing & party titles diversify the portfolio"
    ),
    showlegend=False,
    height=420,
    margin=dict(t=60, b=100, l=100, r=100),
    xaxis=dict(
        title="Sales (M units)",
        showgrid=True, gridcolor="#e2e8f0", zeroline=False,
    ),
    yaxis=dict(title=""),
)
add_source(fig_c, y=-0.22)
assert_no_title_overlap(fig_c)
fig_c.show()

Annual Sales by Platform Era

Show code
platform_year = (
    mario.groupby(["Year", "Platform"], as_index=False)["Global_Sales"]
    .sum()
)
top_platforms = (
    mario.groupby("Platform")["Global_Sales"]
    .sum()
    .nlargest(5)
    .index.tolist()
)
platform_year = (
    platform_year[platform_year["Platform"].isin(top_platforms)]
    .sort_values("Year")
)

PLATFORM_COLORS = {
    "NES":  "#e63946",
    "SNES": "#f59e0b",
    "N64":  "#10b981",
    "GBA":  "#3b82f6",
    "DS":   "#8b5cf6",
    "Wii":  "#06b6d4",
    "3DS":  "#84cc16",
}

fig = go.Figure()
for plat in top_platforms:
    df_p = platform_year[platform_year["Platform"] == plat]
    color = PLATFORM_COLORS.get(plat, "#94a3b8")
    fig.add_trace(
        go.Scatter(
            x=df_p["Year"],
            y=df_p["Global_Sales"],
            mode="lines+markers",
            name=plat,
            line=dict(color=color, width=2),
            marker=dict(size=6),
            hovertemplate=f"{plat} %{{x}}: %{{y:.1f}}M<extra></extra>",
        )
    )
    # 最終年の右端にラベル
    last = df_p.iloc[-1]
    fig.add_annotation(
        x=last["Year"], y=last["Global_Sales"],
        text=f"  {plat}",
        showarrow=False,
        xanchor="left",
        font=dict(size=11, color=color),
    )

fig.update_layout(
    **THEME,
    title=make_title("Each new Nintendo platform resets the Mario sales cycle"),
    xaxis=dict(title="", showgrid=True, gridcolor="#e2e8f0", zeroline=False, dtick=4),
    yaxis=dict(title="Sales (M units)", showgrid=True, gridcolor="#e2e8f0", zeroline=False),
    showlegend=False,
    height=420,
    margin=dict(t=100, b=140, l=80, r=100),
)
fig.add_annotation(
    text="Annual sales per platform · top 5 platforms by total sales  ·  <span style='color:#e63946'>■</span> NES",
    xref="paper", yref="paper",
    x=0, y=1.06,
    showarrow=False,
    font=dict(size=11, color="#64748b"),
    align="left",
)
add_source(fig, y=-0.30)
assert_no_title_overlap(fig)
fig.show()

Key Takeaways

  1. Hit concentration is extreme: The top 5 titles account for roughly two-thirds of total franchise sales, mirroring power-law distributions seen in financial assets
  2. Regional dominance: North America drives the majority of revenue, but Japan and Europe provide meaningful diversification
  3. Genre portfolio effect: Platform games lead in volume, while Mario Kart and party titles create recurring revenue across generations
  4. Platform cycle reset: Each new Nintendo hardware generation produces a fresh sales spike, with the Wii and DS era being the largest

This post is part of the MakeoverMonday weekly data visualization project.

CautionDisclaimer

This analysis is for educational and practice purposes only. Data visualizations and interpretations are based on the provided dataset and may not represent complete or current information. Sales figures are historical and sourced from VGChartz estimates.