Back to Blog
General

The Most-Loved and Most-Hated Moments of the World Cup, According to Social Data

June 28, 2026
12 min read
S
By SociaVault Team
world cupsocial media datasentiment analysisfan engagementdata analysis

The Most-Loved and Most-Hated Moments of the World Cup, According to Social Data

TL;DR: Every World Cup produces a handful of moments that the internet either adores or despises. This post shows you how to build a data-driven leaderboard of those moments by pulling reactions from X, TikTok, YouTube, and Reddit, scoring each one on both engagement and sentiment, and ranking them honestly, with working code in JavaScript and Python and a clear-eyed take on where the method gets fuzzy.

There is a specific kind of moment that defines every World Cup. A last-minute winner that sends a nation into the streets. A red card that half the planet thinks was a disgrace. A goal celebration that becomes a meme within the hour. A penalty decision that fans will still be arguing about at the next tournament. These are the moments people remember, and they live on social media long after the final whistle.

The fun question is: can you rank them? Not by gut feel or by what the pundits decide, but by what the data actually says. Which moment was the most loved? Which was the most hated? And which one simply broke the internet regardless of whether people were happy or furious?

That is what we are building here: a moment leaderboard powered by social data. It is part data project, part storytelling engine, and it makes for a genuinely great post-tournament recap, a broadcaster segment, or a brand content piece. Let's get into it.

The Idea: Two Axes, Not One

The mistake most "best moments" rankings make is using a single axis. They rank by volume, so the moment everyone talked about wins, even if everyone was complaining. Or they rank by sentiment, so a niche moment that fifty people adored beats a moment that moved millions.

A good moment leaderboard uses two axes:

  • Reach and engagement. How big was the reaction? How many people posted, liked, shared, and commented?
  • Sentiment. Was the reaction positive or negative?

Plot moments on those two axes and you get four quadrants:

  • Most loved: high engagement, strongly positive. The celebrations and the wonder goals.
  • Most hated: high engagement, strongly negative. The controversial calls and the bottle jobs.
  • Quiet wins: lower engagement, positive. Nice moments that did not go huge.
  • Forgettable: low engagement, neutral. The stuff nobody reacted to.

The two interesting quadrants, most loved and most hated, are your leaderboard. Everything in this post builds toward filling them in.

Step 1: Define Your Moment List

A moment is just a search topic with a label. Before you pull any data, write down the moments you want to evaluate. During a tournament you would add to this list as things happen. Each entry needs a human label and a set of search terms that capture how people refer to it.

Keep your search terms balanced. If one moment gets five generous search phrases and another gets one narrow one, your engagement comparison is rigged before it starts. Aim for a comparable breadth of terms per moment.

const moments = [
  {
    id: "final-winner",
    label: "Stoppage-time winner in the final",
    terms: ["last minute winner", "stoppage time goal final", "winning goal"],
  },
  {
    id: "controversial-pen",
    label: "Disputed penalty in the semi",
    terms: ["penalty decision semi", "var penalty", "robbed penalty"],
  },
  {
    id: "wonder-goal",
    label: "Long-range wonder goal",
    terms: ["wonder goal", "screamer", "goal of the tournament"],
  },
  {
    id: "red-card",
    label: "Early red card in the quarter",
    terms: ["red card quarter", "sent off", "straight red"],
  },
];
moments = [
    {
        "id": "final-winner",
        "label": "Stoppage-time winner in the final",
        "terms": ["last minute winner", "stoppage time goal final", "winning goal"],
    },
    {
        "id": "controversial-pen",
        "label": "Disputed penalty in the semi",
        "terms": ["penalty decision semi", "var penalty", "robbed penalty"],
    },
    {
        "id": "wonder-goal",
        "label": "Long-range wonder goal",
        "terms": ["wonder goal", "screamer", "goal of the tournament"],
    },
    {
        "id": "red-card",
        "label": "Early red card in the quarter",
        "terms": ["red card quarter", "sent off", "straight red"],
    },
]

Step 2: Pull Reactions Per Moment

For each moment, search every platform for every term, then pool the results. We will reuse the same search helper from the rest of this series. Each request is about one credit, and the relevant endpoints are /v1/scrape/twitter/search, /v1/scrape/tiktok/search-keyword, /v1/scrape/reddit/search, and /v1/scrape/youtube/video/comments when you have a specific reaction video.

const BASE = "https://api.sociavault.com";
const HEADERS = { "X-API-Key": "YOUR_API_KEY" };

async function get(path, params) {
  const res = await fetch(`${BASE}${path}?${new URLSearchParams(params)}`, {
    headers: HEADERS,
  });
  if (!res.ok) return [];
  const { data } = await res.json();
  return data.results ?? data.posts ?? data.comments ?? [];
}

async function pullMoment(moment) {
  const posts = [];
  for (const term of moment.terms) {
    const [x, tt, rd] = await Promise.all([
      get("/v1/scrape/twitter/search", { query: term, limit: "50" }),
      get("/v1/scrape/tiktok/search-keyword", { query: term, limit: "50" }),
      get("/v1/scrape/reddit/search", { query: term, limit: "50" }),
    ]);
    posts.push(...x, ...tt, ...rd);
  }
  return { ...moment, posts };
}
import requests

BASE = "https://api.sociavault.com"
HEADERS = {"X-API-Key": "YOUR_API_KEY"}

def get(path, params):
    res = requests.get(f"{BASE}{path}", headers=HEADERS, params=params)
    if not res.ok:
        return []
    data = res.json()["data"]
    return data.get("results") or data.get("posts") or data.get("comments") or []

def pull_moment(moment):
    posts = []
    for term in moment["terms"]:
        posts += get("/v1/scrape/twitter/search", {"query": term, "limit": "50"})
        posts += get("/v1/scrape/tiktok/search-keyword", {"query": term, "limit": "50"})
        posts += get("/v1/scrape/reddit/search", {"query": term, "limit": "50"})
    return {**moment, "posts": posts}

Step 3: Score Engagement and Sentiment

Now compute both axes for each moment. Engagement is a weighted sum of likes, comments, and shares. Sentiment uses the same transparent lexicon approach from our sentiment analysis post, so the score is something you can actually explain.

function engagementScore(posts) {
  return posts.reduce((sum, p) => {
    const likes = p.like_count ?? p.likes ?? 0;
    const comments = p.comment_count ?? p.reply_count ?? 0;
    const shares = p.share_count ?? p.retweet_count ?? 0;
    return sum + likes + comments * 2 + shares * 3;
  }, 0);
}

const POSITIVE = new Set([
  "amazing",
  "incredible",
  "love",
  "loved",
  "win",
  "brilliant",
  "beautiful",
  "class",
  "legend",
  "hero",
  "magic",
  "stunning",
  "deserved",
  "joy",
  "best",
  "wonderful",
  "unbelievable",
  "clinical",
  "screamer",
  "worldie",
]);
const NEGATIVE = new Set([
  "terrible",
  "awful",
  "hate",
  "robbed",
  "disgrace",
  "embarrassing",
  "boring",
  "cheat",
  "dive",
  "disallowed",
  "worst",
  "rigged",
  "shameful",
  "choke",
  "bottled",
  "overrated",
  "disappointing",
  "scandal",
  "joke",
]);

function sentimentScore(posts) {
  let pos = 0;
  let neg = 0;
  for (const p of posts) {
    const text = (
      p.text ??
      p.description ??
      p.title ??
      p.selftext ??
      ""
    ).toLowerCase();
    const words = text.match(/[a-z']+/g) ?? [];
    for (const w of words) {
      if (POSITIVE.has(w)) pos++;
      if (NEGATIVE.has(w)) neg++;
    }
  }
  const total = pos + neg;
  return total === 0 ? 0 : Number(((pos - neg) / total).toFixed(3));
}
def engagement_score(posts):
    total = 0
    for p in posts:
        likes = p.get("like_count") or p.get("likes") or 0
        comments = p.get("comment_count") or p.get("reply_count") or 0
        shares = p.get("share_count") or p.get("retweet_count") or 0
        total += likes + comments * 2 + shares * 3
    return total

POSITIVE = {
    "amazing", "incredible", "love", "loved", "win", "brilliant", "beautiful",
    "class", "legend", "hero", "magic", "stunning", "deserved", "joy", "best",
    "wonderful", "unbelievable", "clinical", "screamer", "worldie",
}
NEGATIVE = {
    "terrible", "awful", "hate", "robbed", "disgrace", "embarrassing", "boring",
    "cheat", "dive", "disallowed", "worst", "rigged", "shameful", "choke",
    "bottled", "overrated", "disappointing", "scandal", "joke",
}

import re

def sentiment_score(posts):
    pos = neg = 0
    for p in posts:
        text = (p.get("text") or p.get("description") or p.get("title")
                or p.get("selftext") or "").lower()
        for w in re.findall(r"[a-z']+", text):
            if w in POSITIVE:
                pos += 1
            if w in NEGATIVE:
                neg += 1
    total = pos + neg
    return round((pos - neg) / total, 3) if total else 0

Step 4: Build the Leaderboard

Pull every moment, score both axes, then sort. To rank "most loved" you want high engagement and positive sentiment together, so multiply a normalized engagement value by a positivity factor. For "most hated" you do the same with negativity. Normalizing engagement to a 0-to-1 range keeps one viral moment from swamping everything.

async function buildLeaderboard(moments) {
  const scored = [];
  for (const m of moments) {
    const full = await pullMoment(m);
    scored.push({
      id: m.id,
      label: m.label,
      engagement: engagementScore(full.posts),
      sentiment: sentimentScore(full.posts),
      sampleSize: full.posts.length,
    });
  }

  const maxEng = Math.max(...scored.map((s) => s.engagement), 1);
  for (const s of scored) {
    s.engagementNorm = s.engagement / maxEng; // 0..1
    // loved: big reaction AND positive
    s.lovedScore = Number(
      (s.engagementNorm * Math.max(s.sentiment, 0)).toFixed(3),
    );
    // hated: big reaction AND negative
    s.hatedScore = Number(
      (s.engagementNorm * Math.max(-s.sentiment, 0)).toFixed(3),
    );
  }

  const mostLoved = [...scored].sort((a, b) => b.lovedScore - a.lovedScore);
  const mostHated = [...scored].sort((a, b) => b.hatedScore - a.hatedScore);
  return { mostLoved, mostHated };
}

const { mostLoved, mostHated } = await buildLeaderboard(moments);
console.log("MOST LOVED:");
mostLoved.forEach((m, i) =>
  console.log(`${i + 1}. ${m.label} (${m.lovedScore})`),
);
console.log("MOST HATED:");
mostHated.forEach((m, i) =>
  console.log(`${i + 1}. ${m.label} (${m.hatedScore})`),
);
def build_leaderboard(moments):
    scored = []
    for m in moments:
        full = pull_moment(m)
        scored.append({
            "id": m["id"],
            "label": m["label"],
            "engagement": engagement_score(full["posts"]),
            "sentiment": sentiment_score(full["posts"]),
            "sample_size": len(full["posts"]),
        })

    max_eng = max((s["engagement"] for s in scored), default=1) or 1
    for s in scored:
        s["engagement_norm"] = s["engagement"] / max_eng
        s["loved_score"] = round(s["engagement_norm"] * max(s["sentiment"], 0), 3)
        s["hated_score"] = round(s["engagement_norm"] * max(-s["sentiment"], 0), 3)

    most_loved = sorted(scored, key=lambda s: s["loved_score"], reverse=True)
    most_hated = sorted(scored, key=lambda s: s["hated_score"], reverse=True)
    return most_loved, most_hated

most_loved, most_hated = build_leaderboard(moments)
print("MOST LOVED:")
for i, m in enumerate(most_loved, 1):
    print(f"{i}. {m['label']} ({m['loved_score']})")
print("MOST HATED:")
for i, m in enumerate(most_hated, 1):
    print(f"{i}. {m['label']} ({m['hated_score']})")

There it is: two ranked lists, derived from data, ready to drop into a recap. The wonder goal and the stoppage-time winner usually float to the top of loved. The disputed penalty and the red card usually own the hated list. When a moment lands high on both, that is the true internet-breaker, the one that split the audience right down the middle.

Step 5: Add Context That Makes It a Story

A leaderboard is a skeleton. What makes it compelling is the texture around each entry. A few cheap additions:

  • Pull the single highest-engagement post per moment as a representative quote. One brilliant reaction tells the story better than a number.
  • Note the platform split. A moment that is loved on TikTok but hated on X is a better story than one with uniform reaction. Score each platform separately to surface that.
  • Track when the reaction peaked. Timestamps on the posts show you how fast the moment caught fire.
def top_post(moment_posts):
    if not moment_posts:
        return None
    return max(moment_posts, key=lambda p: (
        (p.get("like_count") or p.get("likes") or 0)
        + (p.get("share_count") or p.get("retweet_count") or 0) * 3
    ))

Drop the top post under each leaderboard entry and the recap writes itself.

Being Honest About the Method

This is a fun project, and it produces real signal, but it is not a precise instrument. Be upfront about that, especially if you are publishing the results:

  • Sentiment scoring is approximate. The lexicon misses sarcasm, slang, emoji, and every non-English reaction. Football fans are sarcastic by nature, so the error is real. Use the scores for relative ranking, not as a precise verdict.
  • Search is a sample. You are pulling a slice of the conversation per term, not every post in existence. Keep your term breadth balanced across moments so the comparison stays fair.
  • Engagement counts are point-in-time. Numbers keep climbing after you pull them. Snapshot all moments in the same window so they are comparable.
  • Term selection shapes the outcome. The moments you choose and the words you search for determine what can win. Be deliberate and transparent about both.
  • Correlation is not intent. A post mentioning a moment and a sentiment word is not proof of how that person felt overall. Read a sample before you commit to a headline.

The honest framing is this: the leaderboard tells you, within the slice of conversation you sampled and the limits of simple scoring, which moments leaned most loved and most hated. That is a genuinely interesting, defensible claim. Just do not dress it up as a precise census of global opinion. If you want to tighten the accuracy, swap the lexicon for a maintained sentiment library or a multilingual model, as we discuss in the sentiment analysis post.

Make It a Repeatable Recap

Here is the workflow end to end:

  1. Maintain a moment list throughout the tournament, adding entries as things happen.
  2. After the final, snapshot every moment across platforms in one window.
  3. Score engagement and sentiment with a method you can explain.
  4. Rank into most-loved and most-hated leaderboards.
  5. Decorate each entry with a top post, a platform split, and a peak time.
  6. Publish with an honest note on method and limits.

The result is a recap that is more credible than a pundit's hot takes and more interesting than a raw view count, because it is grounded in what fans actually said.

Where to Go Next

This is the capstone of the World Cup series, and it builds on everything before it:

Want to build your own moment leaderboard? Start free with SociaVault and your 50 credits are enough to score a full slate of moments. Every endpoint is documented in the docs.

Four years from now, people will still argue about which moment of this tournament was the best and which was the worst. The difference is that you will have the receipts.

Found this helpful?

Share it with others who might benefit

Ready to Try SociaVault?

Start extracting social media data with our powerful API. No credit card required.