One country silhouette, north-up and centered — pick its name from four choices. The three decoys are countries of similar area (chosen with the areakm2 property), so the size gives nothing away — you have to know the outline. Both the geometry and the country names come straight from the MapJSON /v1/geo endpoint.
Stuck? Hint zooms out to the neighbouring countries — or, for an island, just the nearest land around it. Skip sets one aside to come back around later. Each shape loads fast (the world comes in at a light auto tier), then sharpens to high detail for the country on screen; silhouettes are re-centered on their own longitude so they never split at the map's edge.
1 · the whole world at auto detail — a light tier, fast first paint, drives the choices + hint
https://api.mapjson.com/v1/geo?layer=countries&detail=auto&properties=name,iso2,areakm2&format=geojson
2 · then just the country on screen, at high detail — a crisp silhouette (filter={iso2})
https://api.mapjson.com/v1/geo?layer=countries&detail=high&filter=FR&properties=name,iso2,areakm2&format=geojson
The first call uses detail=auto, so the server returns the lightest world tier that still contains every country — enough to build the choices and the hint's context. Each round then fetches only the shown country at detail=high (cached by ISO2): the game starts fast, yet every silhouette sharpens to the 10 m coastline. (Using auto on a single country would go the other way — the coarsest tier it appears in, a blocky outline.)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Map Shape Guess</title>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<style>
body { font-family: sans-serif; margin: 2rem; max-width: 820px; }
#map-wrap { position: relative; background: #e9e5dd; border-radius: 4px; overflow: hidden; }
#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; }
.loading { position: absolute; inset: 0; display: none; align-items: center; justify-content: center;
font: 13px monospace; color: #8a857c; }
#choices { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.6rem; margin: 1rem 0; }
.choice { font: 500 15px sans-serif; padding: 0.75rem 1rem; border: 1px solid #ddd;
border-radius: 8px; background: #fff; cursor: pointer; text-align: left; }
.choice.ok { background: #e6f4ec; border-color: #4caf7d; color: #256f47; }
.choice.no { background: #f9e7e6; border-color: #d6534e; color: #a23530; }
</style>
</head>
<body>
<div id="bar"><span id="prompt-msg">Loading…</span> <button id="hint">Hint</button> <button id="skip">Skip →</button></div>
<div id="map-wrap"><div class="score" id="score"></div><div class="loading" id="loading">loading…</div></div>
<div id="choices"></div>
<script>
const BG = "#e9e5dd", SHAPE = "#3a5e70", SHAPE_OK = "#4caf7d", SHAPE_NO = "#d6534e", LAND_CTX = "#c9c0b0";
const MIN_AREA_KM2 = 25000; // drop specks too small to recognize
const NUM_CHOICES = 4;
const NEIGHBORS = 5; // nearby countries the Hint frames around the target
const width = 800, height = 480;
const API = "https://api.mapjson.com/v1/geo?layer=countries&properties=name,iso2,areakm2&format=geojson";
const wrap = document.getElementById("map-wrap");
const promptEl = document.getElementById("prompt-msg");
const scoreEl = document.getElementById("score");
const choicesEl = document.getElementById("choices");
const hintBtn = document.getElementById("hint");
const loadingEl = document.getElementById("loading");
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", BG);
const g = svg.append("g");
const projection = d3.geoNaturalEarth1();
const path = d3.geoPath(projection);
let allFeatures = [], features = [], queue = [], qi = 0, target = null, hero = null,
locked = false, hintOn = false, lastCorrect = false, correct = 0, wrong = 0;
const hiCache = {}, pending = {}; // high-detail geometry cached by ISO2
// Whole world at auto (the server picks a light tier) for a fast first paint; it drives
// the choices + hint. Each country is then re-fetched at high detail so its silhouette
// is razor-sharp — auto on a single country would return a coarse, blocky outline.
d3.json(API + "&detail=auto")
.then((geo) => {
allFeatures = geo.features.filter((d) => d.properties && d.properties.name !== "Antarctica");
allFeatures.forEach((f) => (f._c = d3.geoCentroid(f))); // cache centroids for the Hint
features = allFeatures.filter((d) => {
const p = d.properties;
return p.iso2 && p.name && (p.areakm2 || 0) >= MIN_AREA_KM2;
});
queue = shuffle(features.slice());
updateScore();
next();
});
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 updateScore() { scoreEl.innerHTML = `<span class="s-ok">✓ ${correct}</span><span class="s-no">✗ ${wrong}</span>`; }
function targetFill() { return locked ? (lastCorrect ? SHAPE_OK : SHAPE_NO) : SHAPE; }
function setLoading(on) { loadingEl.style.display = on ? "flex" : "none"; }
// Draw a set of features, re-centered on `center` longitude so nothing splits at the
// antimeridian and fit to `fitTo`; `heroFeat` is filled dark, the rest muted context.
function paint(list, center, fitTo, pad, heroFeat) {
hero = heroFeat; setLoading(false);
projection.rotate([-center, 0]).fitExtent([[pad, pad], [width - pad, height - pad]], fitTo);
g.selectAll("path").data(list, (d) => d.properties.iso2 || d.properties.name).join("path")
.attr("d", path)
.attr("fill", (d) => d === hero ? targetFill() : LAND_CTX)
.attr("stroke", BG).attr("stroke-width", (d) => d === hero ? 1.2 : 0.5)
.attr("stroke-linejoin", "round");
}
// Plain mode: the silhouette alone, always at high detail. Rather than flash the coarse
// world-tier shape first, we wait for the high fetch — the country pops in already sharp
// (usually instantly, since next() prefetches upcoming countries into the cache).
function drawShape() {
const hi = hiCache[target.properties.iso2];
if (hi) { paint([hi], hi._c[0], hi, 30, hi); }
else { g.selectAll("path").remove(); setLoading(true); fetchHigh(target); }
}
// The N countries whose centroids sit closest to the target's.
function neighbors(t, n) {
return allFeatures
.filter((f) => f !== t)
.sort((a, b) => d3.geoDistance(t._c, a._c) - d3.geoDistance(t._c, b._c))
.slice(0, n);
}
// Hint mode: frame the map around the target plus its nearest neighbours — so a
// bordered country shows the ones it touches, and an island pulls back only to the
// nearest land around it, never the whole world. All land in view is drawn for context.
function drawHint() {
const fit = { type: "FeatureCollection", features: [target, ...neighbors(target, NEIGHBORS)] };
paint(allFeatures, target._c[0], fit, 26, target);
}
function render() { hintOn ? drawHint() : drawShape(); }
// Fetch just this country at high detail (filter={ISO2}) and cache it, then redraw if
// it's still the one on screen. Choices and area still come from the world set.
function fetchHigh(t) {
const iso = t.properties.iso2;
if (hiCache[iso] || pending[iso]) return;
pending[iso] = true;
d3.json(`${API}&detail=high&filter=${iso}`).then((geo) => {
pending[iso] = false;
const f = (geo.features || []).find((x) => x.properties.iso2 === iso) || (geo.features || [])[0];
if (!f) return;
f._c = t._c; // reuse the world-tier centroid so framing stays put
hiCache[iso] = f;
if (target === t && !hintOn) drawShape();
}).catch(() => { pending[iso] = false; });
}
// Correct answer + 3 decoys drawn from the countries CLOSEST in area — so scale
// is no hint. (fitExtent normalizes on-screen size anyway; this fights memory.)
function sampleChoices(t) {
const ta = t.properties.areakm2 || 0;
const near = features
.filter((f) => f.properties.iso2 !== t.properties.iso2)
.sort((a, b) => Math.abs((a.properties.areakm2 || 0) - ta) - Math.abs((b.properties.areakm2 || 0) - ta))
.slice(0, 40);
shuffle(near);
return shuffle([t, ...near.slice(0, NUM_CHOICES - 1)]);
}
function renderChoices(opts) {
choicesEl.innerHTML = "";
for (const f of opts) {
const b = document.createElement("button");
b.className = "choice";
b.textContent = f.properties.name;
b.addEventListener("click", () => guess(f, b));
choicesEl.appendChild(b);
}
}
function next() {
if (qi >= queue.length) { shuffle(queue); qi = 0; }
target = queue[qi++]; locked = false; hintOn = false;
hintBtn.textContent = "Hint"; hintBtn.disabled = false;
promptEl.textContent = "Which country has this shape?";
render();
renderChoices(sampleChoices(target));
for (let i = 0; i < 3 && i < queue.length; i++) fetchHigh(queue[(qi + i) % queue.length]); // warm the next few
}
function guess(f, btn) {
if (locked) return;
locked = true; hintBtn.disabled = true;
lastCorrect = f.properties.iso2 === target.properties.iso2;
const buttons = choicesEl.querySelectorAll("button");
buttons.forEach((b) => (b.disabled = true));
g.selectAll("path").filter((d) => d === hero).attr("fill", targetFill());
if (lastCorrect) {
correct++; btn.classList.add("ok");
promptEl.textContent = `✓ Correct — ${target.properties.name}`;
updateScore(); setTimeout(next, 900);
} else {
wrong++; btn.classList.add("no");
buttons.forEach((b) => { if (b.textContent === target.properties.name) b.classList.add("ok"); });
promptEl.textContent = `✗ You picked ${f.properties.name} — it was ${target.properties.name}`;
updateScore(); setTimeout(next, 1600);
}
}
hintBtn.addEventListener("click", () => {
if (locked) return;
hintOn = !hintOn;
hintBtn.textContent = hintOn ? "Hide hint" : "Hint";
render();
});
document.getElementById("skip").addEventListener("click", () => {
if (locked) return;
queue.push(target); // re-ask later, no reveal
next();
});
</script>
</body>
</html>