Random was unfair and my compass arrow was wrong: two fixes in a daily geography game
DailyGeo is a daily geography guessing game in English, French, Spanish and German. Everyone gets the same mystery capital (or country, in the second mode) each day. You type a guess and each clue column compares it to t
DailyGeo is a daily geography guessing game in English, French, Spanish and German. Everyone gets the same mystery capital (or country, in the second mode) each day. You type a guess and each clue column compares it to the answer: distance, compass direction, population, currency and official languages, plus flag colours and borders in the countries mode. If you are stuck, hints unlock at 4, 6 and 7 misses (the continent, then a land neighbour, then a third clue such as a language).
The game went live on 22 July 2026. Two parts of it looked correct in code review and still played badly. Both were maths problems, and both had a cheap fix once I measured them. This post covers what went wrong and what I changed.
Problem 1: a uniform hash picks a bad daily answer
The first version picked the answer the way most daily games do. Hash the date with a salt, take it modulo the pool size:
const index = fnv1a32(`${date}:${salt}`) % pool.length;
It is deterministic, it needs no server and no stored schedule, and any client can compute any day. I shipped it without thinking further.
After 69 days live I replayed the hash over those dates and counted the capitals mode. Singapore had come up 4 times. London had come up once, Washington and Paris never. Each draw was fair. The sequence was not, because independent uniform draws clump, and "uniform over the pool" is not what a player means by a good daily puzzle. A player wants famous places reasonably often, no repeats for a while, and some variety between continents.
What I replaced it with
From 29 September the answer comes from a deterministic schedule instead. It still has the same signature as the old picker, (pool, idOf, date, salt) => item, so the rest of the game did not change.
Every place has a hand-assigned fame tier from 1 to 4, and a tier maps to a draw weight of 8, 4, 2 or 1. I tried computing fame from Wikipedia pageviews first. It tracks the news, not fame: Nepal and Albania ranked above Italy. So the tiers are a hand-curated table, with separate tables for countries and capitals (Australia is tier 1 as a country, Canberra only tier 2 as a capital). A unit test fails if any possible answer is missing from the table.
Each day is then picked in two steps, both by smooth weighted round robin:
- Continent. A continent's weight is the sum of its places' weights raised to the power 0.7. The exponent flattens things so Europe and Asia still lead but the Americas and Oceania get a real share. The same continent can never come up two days in a row.
- Place within that continent. Same round robin over the fame weights.
Smooth weighted round robin works like this: every candidate gains credit equal to its weight each day, the candidate with the most credit is picked, and its credit drops by the total weight. Heavy places come up more often, but their appearances are spread out instead of clumped, which a weighted coin flip cannot promise.
On top of that there is a hard minimum gap per tier: 30 days for tier 1, 45, 90, then 150 days for tier 4. Without it, Oceania (four possible answers) would bring Papua New Guinea back every two months. A small per-day hash jitter keeps the order from settling into a cycle that regular players could learn.
The simulated result over two years is roughly Europe 32%, Asia 24%, Africa 19%, the Americas 21%, Oceania 4%. The tests check the properties directly: no place returns inside its gap, never the same continent two days running, every tier 1 place appears in the first 120 days, every continent gets at least 3% of days.
Keeping it a pure function
The constraint that shaped the design: any day, past or future, has to resolve the same way everywhere. Archived days, replays, shared results and the server that checks a submitted win all compute the answer independently.
So the schedule is replayed from its start date every time, and memoised per pool and salt. Days before the switch still go through the original hash, byte for byte, so every archived result still points at what was actually played. The 60 hash days before the switch count as already played, so the change did not bring back the previous week's answers.
Adding a place to the pool was the tricky part. If you append an item to a hash-based pool, % pool.length changes, and every past day reshuffles. Here, additions go through a table with a join date:
export const POOL_ADDITIONS = {
"capital:Ramallah": "2026-10-22",
"country:Palestine": "2026-10-22",
// ...
};
Before its join date the place is not in the pool at all, so every earlier day resolves exactly as before, and the schedule continues from its own state with one more candidate. The join date has to be a date no player has seen yet. The daily rolls over at each viewer's local midnight, so a date is already live at UTC+14 from 10:00 UTC the day before. When Palestine and Hong Kong were added on 20 October, the earliest safe join date was the 22nd.
Problem 2: the compass arrow disagreed with the map
The direction clue shows an arrow from your guess toward the answer, snapped to eight compass points. My shared engine computed it as the initial great-circle bearing, the heading you would leave on for the shortest flight. That is the geodesically correct answer, and next to the map it looks like a broken arrow.
The reference map in the game is an equirectangular projection, and on a flat map the great-circle heading often does not match what you see. Ottawa to Stockholm read NE where the map plainly shows E. Beijing to Moscow read NW where the map shows W. Dublin to Wellington read N where the map shows SW.
I measured it over all 29,412 ordered pairs of the 172 capitals in the dataset at the time:
| Bearing | Disagrees with the map | Off by 2+ compass steps | Mean angular error |
|---|---|---|---|
| Great circle | 32.3% | 2.7% | 15.6° |
| Rhumb line | 6.8% | 0% | 2.4° |
A rhumb line is the path of constant compass heading. On a Mercator projection it is a straight line, so the bearing is just atan2 of the run over the rise in Mercator coordinates:
export function rhumbBearing(from: LatLng, to: LatLng): number {
const phi1 = toRad(clampLat(from.lat));
const phi2 = toRad(clampLat(to.lat));
const dLambda = toRad(normalizeLng(to.lng - from.lng));
const dPsi = Math.log(
Math.tan(Math.PI / 4 + phi2 / 2) / Math.tan(Math.PI / 4 + phi1 / 2),
);
const deg = (Math.atan2(dLambda, dPsi) * 180) / Math.PI;
return (deg + 360) % 360;
}
Three edge cases all occur in the real data. Equal latitudes give dPsi === 0, and atan2(x, 0) returns exactly ±π/2, so due east and west need no special case and nothing divides by zero. The longitude difference is folded into [-180, 180] so Suva to Apia goes the short way across the antimeridian. Latitudes are clamped to 89.9999 so a pole could never produce Infinity or NaN, even though no capital sits there.
It is also the more honest reading of what the cell says. "The answer is north-east of here" is a statement about a compass heading, and a constant compass heading is what a rhumb line is.
I considered adding a special "other side of the world" state for antipodal pairs and dropped it, because the rhumb bearing stays consistent with the map even at half a planet's distance. Hiding the arrow there would hide information that is now correct.
What I took from both
Both bugs passed every test I had, because the tests checked the code against my model of the problem. The model was wrong both times. A fair schedule to a player is not a uniform random one, and a direction to a player is what the map shows. What found them was counting outputs over the whole dataset (the first 69 days of answers, 29,412 bearings) and comparing against what a player actually sees.
If you want to check the arrow yourself, the Capitals mode has a map you can open next to the board. I am curious how other daily games handle answer scheduling, especially when the pool grows after launch.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.