Skip to content

Similarities (/challenges/similarities) shows a country name and two flags that people mix up, for example Romania and Chad. The player picks the flag that belongs to the name. A run is 10 rounds; 6 or more correct is a win.

All the difficulty lives in the pair list. The engine merges three hand-maintained sources into one set of unordered pairs, drops pairs that cannot be played, shuffles, and takes the first 10.

The counts in the diagram were computed by running getAllConfusionPairs() against the data on the verified date. They change whenever the data files change.

SIMILAR_FLAGS is derived at module load from COUNTRY_ATTRIBUTES, which merges the per-code rows in flag-attributes-data.ts:

packages/shared/src/game-logic/data/flag-patterns.ts
function deriveSimilarFlags(): Record<string, string[]> {
return Object.fromEntries(
Object.entries(COUNTRY_ATTRIBUTES).map(([code, attrs]) => [
code,
attrs.similarFlags ?? [],
])
);
}
packages/shared/src/game-logic/data/flag-attributes-data.ts
AD: {
colorPatterns: ["yellowBlueRed", "redYellow"],
motifs: ["verticalStripes", "animals", "emblem", "eagles", "birds"],
colorCount: 4,
stripesVertical: true,
similarFlags: ["MD", "RO", "TD"],
},

116 codes have a non-empty similarFlags list. The lists are directed (A may list B without B listing A); the pair key makes them symmetric.

A local array in the route module. Most entries overlap the other sources; 7 of the 30 appear only here.

apps/web/src/routes/challenges/similarities/similarities-logic.ts
const CURATED_PAIRS: [string, string][] = [
["RO", "TD"],
["ID", "MC"],
["LI", "HT"],
["IE", "CI"],
["GR", "UY"],
// … 25 more, through ["SE", "NO"]
];

A static map of countries that share history or a regional flag family (ex-Yugoslav, Baltic, Pan-Arab, Pan-African, Nordic, Central Asian):

packages/shared/src/game-logic/data/historical-confusion.ts
export const HISTORICAL_CONFUSION_PAIRS: HistoricalConfusionPairs = {
SI: ["SK", "HR", "CZ", "RS"],
SK: ["SI", "CZ", "HR", "RS"],
// …
LV: ["LT", "EE"],
// …
TW: ["CN"],
CN: ["TW"],
};

The same map feeds COUNTRY_ATTRIBUTES.historicalConfusion and gives an 80-point bonus to distractors in expert-mode option scoring (getHistoricalConfusionBonus in game-logic.ts). 91 of its 130 pairs appear in no other source.

apps/web/src/routes/challenges/similarities/similarities-logic.ts
/** Country codes may contain hyphens (e.g. GB-SCT); do not join pairs with "-". */
const CONFUSION_PAIR_DELIM = "\u001f";
export function confusionPairKey(codeA: string, codeB: string): string {
return [codeA, codeB].sort().join(CONFUSION_PAIR_DELIM);
}
function isParentSubdivisionPair(codeA: string, codeB: string): boolean {
const [parent, child] = codeA.length <= codeB.length ? [codeA, codeB] : [codeB, codeA];
return child.startsWith(`${parent}-`);
}

getAllConfusionPairs adds every source into one Set of keys, parses them back, and filters out parent/subdivision pairs.

apps/web/src/routes/challenges/similarities/similarities-logic.ts
export function generateChallengePairs(count = 10): SimilaritiesChallengePair[] {
const availablePairs = getAllConfusionPairs().filter(
([left, right]) => hasPlayableCountries(left, right) && left !== right
);
const rounds: SimilaritiesChallengePair[] = [];
const shuffledPairs = shuffleArray(availablePairs);
for (const [codeA, codeB] of shuffledPairs) {
if (rounds.length >= count) {
break;
}
const round = toChallengePair(codeA, codeB);
if (round) {
rounds.push(round);
}
}
return rounds;
}

toChallengePair looks both codes up in gameCountries and uses Math.random() > 0.5 to decide which one is the answer. The component then shuffles the two flags again for left/right placement (optionOrder = shuffleArray([pair.correct, pair.confusedWith])) and prefetches every flag in the run (up to 20) up front.

apps/web/src/routes/challenges/similarities/SimilaritiesChallenge.svelte
const didWin = score >= Math.ceil(totalQuestions * 0.6);
if (didWin) {
void playChallengeWinSound();
}
await completeChallenge({
didWin,
completionKey: `${score}:${totalQuestions}`,
guessCount: ROUNDS,
});

The run also tracks streak and bestStreak; they appear on the result screen and do not affect the win.

Write SS, CC, HH for the unordered pair sets of the three sources. On the verified date:

SetPairsOnly in this set
SS (SIMILAR_FLAGS)180126
CC (CURATED_PAIRS)307
HH (HISTORICAL_CONFUSION_PAIRS)13091
S∪C∪HS \cup C \cup H278
After parent/subdivision filter276
Both codes in gameCountries265

The 11 pairs dropped at the last step involve codes outside the 205-country quiz pool, such as Åland (AX), Puerto Rico (PR), and the Cook Islands (CK). The 265 playable pairs touch 135 distinct countries; the busiest are Iraq (13 pairs), then Slovakia, the UAE, and Palestine (12 each).

shuffleArray is a uniform Fisher–Yates, and every playable pair converts to a round (the null branch in toChallengePair cannot fire after the playability filter). A run is therefore a uniform random 10-subset of P=265P = 265 pairs:

#runs=(26510)≈4.0×1017\#\text{runs} = \binom{265}{10} \approx 4.0 \times 10^{17}

before the independent coin flip for which side of each pair is the answer. No pair repeats within a run. Countries can: a Monte Carlo of 100,000 runs with the production function found at least one repeated country in about 83% of runs, with 18.4 distinct countries on average out of 20 slots.

win  ⟺  score≥⌈0.6⋅N⌉,N=10⇒6\text{win} \iff \text{score} \ge \lceil 0.6 \cdot N \rceil, \qquad N = 10 \Rightarrow 6

A player guessing at random wins each round with probability 12\tfrac12, so

P(win∣random)=∑k=610(10k)2−10=210+120+45+10+11024=3861024≈37.7%P(\text{win} \mid \text{random}) = \sum_{k=6}^{10} \binom{10}{k} 2^{-10} = \frac{210 + 120 + 45 + 10 + 1}{1024} = \frac{386}{1024} \approx 37.7\%

The threshold separates knowledge from luck only loosely. A player who is right 80% of the time wins with probability ∑k≥6(10k)0.8k0.210−k≈96.7%\sum_{k \ge 6} \binom{10}{k} 0.8^k 0.2^{10-k} \approx 96.7\%.

If the pool ever held fewer than 10 playable pairs, the run would be shorter, and Math.ceil(totalQuestions * 0.6) scales the threshold with it.

5. Threat model, failure modes & edge cases

Section titled “5. Threat model, failure modes & edge cases”
  • Answer in client state. pairs[currentIndex].correct is in memory from the start of the run. Both flags go through FlagImage with hideCountryInAlt during play, so the opaque URL and decoy data-country stop casual inspection, but the component state gives the answer away. There is no XP and no leaderboard for this mode.
  • Hyphenated codes. Keys use U+001F because codes like GB-SCT contain hyphens; "GB-SCT" joined with - would be ambiguous. The note lookups (getSimilarityFact, getLookalikeNote) still join with -. Today their keys are all two-letter codes, so no collision occurs, but a subdivision note would need care.
  • Directed source lists. SIMILAR_FLAGS and HISTORICAL_CONFUSION_PAIRS are not guaranteed symmetric (for example, NE lists IE, CI, and TD, and none of them lists NE). Sorting inside confusionPairKey makes the pool symmetric regardless.
  • Held keys. ignoreKeysUntilKeyup makes a keyboard pick require a key release before the next action, so holding D does not answer a round and skip its reveal.
  • Stale telemetry. flag_confusion_stats keeps counting real multiplayer confusions. The Similarities pool does not read it, so a pair players confuse often only enters the game when someone adds it by hand.
ChoiceAlternativeWhy the code does this
Hand-curated pair sourcesMine flag_confusion_statsCurated pairs are explainable and stable across deploys. Mined pairs would track real confusion but need a pipeline, minimum sample sizes, and a way to ship the result to the client.
Union of three sourcesOne canonical listEach list already exists for another feature (flag attributes, expert distractors). The union reuses them at the cost of overlap and uneven coverage.
Uniform sampling over pairsWeight by difficulty or spread countriesSimple and unbiased. Busy countries such as Iraq show up more often, and most runs repeat a country.
Fixed 10 rounds, 60% to winAdaptive length or Elo-style ratingShort runs and a shareable score. Random play still wins about 38% of the time.
Name-to-flag promptFlag-to-name promptForces the player to compare the two designs side by side, which is the point of the mode.

Non-goals: a server-side answer check, per-player difficulty tuning, and ensuring every pair has an explanatory note.