← Examples
countries · flags · interactive game · Natural Earth

Flag Guess Game

A flag appears — click the country you think it belongs to. Get it right and your score climbs; skip if you're stumped. The map only includes countries large enough to click comfortably (filtered by the areakm2 property), and the flags come straight from flagpedia.net by ISO2 code.

Flag to guess
Loading countries…

Hover any country to see its name, and scroll/pinch to zoom or drag to pan — handy for the smaller ones. Stuck? Hint nudges the map toward the country whose flag is showing. Or skip to set a flag aside — skipped flags come back around later.

API Call

https://api.mapjson.com/v1/geo?layer=countries&detail=medium&properties=name,iso2,areakm2&format=geojson

Flags: https://flagpedia.net/data/flags/h240/{iso2}.png — e.g. .../ua.png for Ukraine.

Code

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Flag Guess Game</title>
  <script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
  <style>
    body { font-family: sans-serif; margin: 2rem; }
    .game-bar { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
    .flag { height: 74px; border: 1px solid #ddd; background: #fff; }
    #map-wrap { position: relative; background: #eaf0f2; max-width: 960px; }
    #map-wrap svg { width: 100%; display: block; }
    .score { position: absolute; top: 10px; right: 10px; background: #1a1a1a; color: #fff;
             font: 13px monospace; padding: 6px 12px; border-radius: 20px; display: flex; gap: 12px; }
    .s-ok { color: #7fdca9; } .s-no { color: #f2a0a0; }
    .zoom-ctrl { position: absolute; left: 10px; bottom: 10px; display: flex; flex-direction: column; gap: 4px; }
    .zoom-ctrl button { width: 30px; height: 30px; font: 16px monospace; cursor: pointer; }
    .tip { position: absolute; pointer-events: none; background: rgba(26,26,26,0.9); color: #fff;
           font: 12px monospace; padding: 3px 8px; border-radius: 4px; white-space: nowrap;
           transform: translate(-50%, -145%); opacity: 0; transition: opacity 0.08s; }
    .tip.show { opacity: 1; }
  </style>
</head>
<body>
  <div class="game-bar">
    <img class="flag" id="flag" alt="Flag to guess">
    <div id="prompt-msg">Loading…</div>
    <button id="hint" type="button">Hint</button>
    <button id="skip" type="button">Skip →</button>
  </div>
  <div id="map-wrap">
    <div class="score" id="score"></div>
    <div class="tip" id="tip"></div>
    <div class="zoom-ctrl" id="zoom-ctrl">
      <button type="button" data-z="in">+</button>
      <button type="button" data-z="out">−</button>
      <button type="button" data-z="reset">⟳</button>
    </div>
  </div>

  <script>
    const OCEAN = "#eaf0f2", LAND = "#c9c3b6", STROKE = "#ffffff",
          CORRECT = "#4caf7d", WRONG = "#d6534e", HILITE = "#555";
    const MIN_AREA_KM2 = 25000;   // drop countries too small to click comfortably
    const flagURL = (iso2) => `https://flagpedia.net/data/flags/h240/${iso2.toLowerCase()}.png`;

    const width = 960, height = 500;
    const wrap = document.getElementById("map-wrap");
    const flag = document.getElementById("flag");
    const promptEl = document.getElementById("prompt-msg");
    const scoreEl = document.getElementById("score");
    const tip = document.getElementById("tip");

    const svg = d3.select(wrap).append("svg")
      .attr("viewBox", `0 0 ${width} ${height}`).style("width", "100%").style("height", "auto");
    svg.append("rect").attr("width", width).attr("height", height).attr("fill", OCEAN);
    const g = svg.append("g");
    const projection = d3.geoNaturalEarth1().rotate([-11, 0]);
    const path = d3.geoPath(projection);

    let paths, queue = [], qi = 0, target = null, locked = false, correct = 0, wrong = 0;

    d3.json("https://api.mapjson.com/v1/geo?layer=countries&detail=medium&properties=name,iso2,areakm2&format=geojson")
      .then((geo) => {
        const eligible = geo.features.filter((d) => {
          const p = d.properties;
          return p.iso2 && p.name && p.name !== "Antarctica" && (p.areakm2 || 0) >= MIN_AREA_KM2;
        });
        projection.fitExtent([[6, 6], [width - 6, height - 6]], { type: "FeatureCollection", features: eligible });

        paths = g.selectAll("path").data(eligible).join("path")
          .attr("d", path).attr("fill", LAND).attr("stroke", STROKE).attr("stroke-width", 0.6)
          .attr("vector-effect", "non-scaling-stroke")
          .style("cursor", "pointer")
          .on("mouseenter", function (event, d) { if (!locked) d3.select(this).attr("stroke", HILITE).attr("stroke-width", 1).raise(); showTip(event, d); })
          .on("mousemove", showTip)
          .on("mouseleave", function () { d3.select(this).attr("stroke", STROKE).attr("stroke-width", 0.6); hideTip(); })
          .on("click", (event, d) => guess(d));

        queue = shuffle(eligible.slice());
        updateScore();
        next();
      });

    // Zoom + pan: scroll/pinch to zoom, drag to pan. A short drag still fires the click.
    const zoom = d3.zoom().scaleExtent([1, 14])
      .extent([[0, 0], [width, height]])
      .translateExtent([[0, 0], [width, height]])
      .on("zoom", (ev) => g.attr("transform", ev.transform));
    svg.call(zoom).on("dblclick.zoom", null);

    document.getElementById("zoom-ctrl").addEventListener("click", (e) => {
      const btn = e.target.closest("button"); if (!btn) return;
      const z = btn.dataset.z;
      if (z === "reset") svg.transition().duration(300).call(zoom.transform, d3.zoomIdentity);
      else svg.transition().duration(200).call(zoom.scaleBy, z === "in" ? 1.6 : 1 / 1.6);
    });

    // Hover tooltip — name follows the cursor over the map.
    function showTip(event, d) {
      const r = wrap.getBoundingClientRect();
      tip.textContent = d.properties.name;
      tip.style.left = (event.clientX - r.left) + "px";
      tip.style.top = (event.clientY - r.top) + "px";
      tip.classList.add("show");
    }
    function hideTip() { tip.classList.remove("show"); }

    // Hint — zoom in a little toward the target country's centre (not too much).
    document.getElementById("hint").addEventListener("click", () => {
      if (!target || locked) return;
      const [cx, cy] = path.centroid(target);
      if (!isFinite(cx)) return;
      const k = 3;
      const t = d3.zoomIdentity.translate(width / 2, height / 2).scale(k).translate(-cx, -cy);
      svg.transition().duration(650).call(zoom.transform, t);
    });

    function shuffle(a) { for (let i = a.length - 1; i > 0; i--) { const j = (Math.random() * (i + 1)) | 0; const t = a[i]; a[i] = a[j]; a[j] = t; } return a; }
    function paint(d, color) { paths.filter((x) => x === d).attr("fill", color); }
    function updateScore() { scoreEl.innerHTML = `<span class="s-ok">✓ ${correct}</span><span class="s-no">✗ ${wrong}</span>`; }

    function next() {
      if (qi >= queue.length) { shuffle(queue); qi = 0; }
      target = queue[qi++]; locked = false;
      paths.attr("fill", LAND);
      hideTip();
      svg.call(zoom.transform, d3.zoomIdentity);   // back to the full view for the new flag
      promptEl.textContent = "Which country flies this flag?"; promptEl.className = "";
      flag.src = flagURL(target.properties.iso2);
    }

    function guess(d) {
      if (locked) return;
      locked = true;
      if (d.properties.iso2 === target.properties.iso2) {
        correct++; paint(d, CORRECT);
        promptEl.textContent = `✓ Correct — ${target.properties.name}`; promptEl.className = "ok";
        updateScore(); setTimeout(next, 900);
      } else {
        wrong++; paint(d, WRONG); paint(target, CORRECT);
        promptEl.textContent = `✗ You picked ${d.properties.name} — it was ${target.properties.name}`; promptEl.className = "no";
        updateScore(); setTimeout(next, 1600);
      }
    }

    document.getElementById("skip").addEventListener("click", () => {
      if (locked) return;
      queue.push(target); // "skip for now" — re-ask this flag later, no reveal
      next();
    });

    // A handful of territories have no flag on flagpedia — skip if the image fails.
    flag.addEventListener("error", () => { if (!locked) next(); });
  </script>
</body>
</html>