Similarities engine
1. Foundational mental model
Section titled “1. Foundational mental model”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.
2. Implementation status & known gaps
Section titled “2. Implementation status & known gaps”3. Concrete implementation
Section titled “3. Concrete implementation”Source 1: SIMILAR_FLAGS
Section titled “Source 1: SIMILAR_FLAGS”SIMILAR_FLAGS is derived at module load from COUNTRY_ATTRIBUTES, which merges the per-code rows in flag-attributes-data.ts:
function deriveSimilarFlags(): Record<string, string[]> { return Object.fromEntries( Object.entries(COUNTRY_ATTRIBUTES).map(([code, attrs]) => [ code, attrs.similarFlags ?? [], ]) );}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.
Source 2: CURATED_PAIRS
Section titled “Source 2: CURATED_PAIRS”A local array in the route module. Most entries overlap the other sources; 7 of the 30 appear only here.
const CURATED_PAIRS: [string, string][] = [ ["RO", "TD"], ["ID", "MC"], ["LI", "HT"], ["IE", "CI"], ["GR", "UY"], // … 25 more, through ["SE", "NO"]];Source 3: HISTORICAL_CONFUSION_PAIRS
Section titled “Source 3: HISTORICAL_CONFUSION_PAIRS”A static map of countries that share history or a regional flag family (ex-Yugoslav, Baltic, Pan-Arab, Pan-African, Nordic, Central Asian):
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.
Merging
Section titled “Merging”/** 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.
Building a run
Section titled “Building a run”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.
Scoring
Section titled “Scoring”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.
4. Internal mechanics & mathematics
Section titled “4. Internal mechanics & mathematics”Pool sizes
Section titled “Pool sizes”Write , , for the unordered pair sets of the three sources. On the verified date:
| Set | Pairs | Only in this set |
|---|---|---|
(SIMILAR_FLAGS) | 180 | 126 |
(CURATED_PAIRS) | 30 | 7 |
(HISTORICAL_CONFUSION_PAIRS) | 130 | 91 |
| 278 | ||
| After parent/subdivision filter | 276 | |
Both codes in gameCountries | 265 |
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).
Sampling
Section titled “Sampling”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 pairs:
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.
The 60% line
Section titled “The 60% line”A player guessing at random wins each round with probability , so
The threshold separates knowledge from luck only loosely. A player who is right 80% of the time wins with probability .
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].correctis in memory from the start of the run. Both flags go throughFlagImagewithhideCountryInAltduring play, so the opaque URL and decoydata-countrystop 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-SCTcontain 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_FLAGSandHISTORICAL_CONFUSION_PAIRSare not guaranteed symmetric (for example,NElistsIE,CI, andTD, and none of them listsNE). Sorting insideconfusionPairKeymakes the pool symmetric regardless. - Held keys.
ignoreKeysUntilKeyupmakes a keyboard pick require a key release before the next action, so holdingDdoes not answer a round and skip its reveal. - Stale telemetry.
flag_confusion_statskeeps 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.
6. Architectural tradeoffs & non-goals
Section titled “6. Architectural tradeoffs & non-goals”| Choice | Alternative | Why the code does this |
|---|---|---|
| Hand-curated pair sources | Mine flag_confusion_stats | Curated 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 sources | One canonical list | Each 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 pairs | Weight by difficulty or spread countries | Simple and unbiased. Busy countries such as Iraq show up more often, and most runs repeat a country. |
| Fixed 10 rounds, 60% to win | Adaptive length or Elo-style rating | Short runs and a shareable score. Random play still wins about 38% of the time. |
| Name-to-flag prompt | Flag-to-name prompt | Forces 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.