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.
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.
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.
<!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>