Skip to content

Every flag in flags.games is identified by one string: its country code. US, FR, GB-SCT, HK. The code is the primary key in the country catalog, the answer in a quiz round, the key in SQLite and Convex stats tables, the input to the opaque CDN token, and the entry in the daily schedule. There is no numeric id and no separate slug.

packages/shared/src/data/countries.ts
export interface Country {
code: string;
continent: string;
name: string;
region: string;
}

Codes are uppercase in the catalog. Most are ISO 3166-1 alpha-2. Seven are subdivision-style codes for flags that ISO does not list as countries: GB-SCT, GB-WLS, GB-ENG, SO-SL, GE-AB, GE-OS, MD-SN. Two meanings share the namespace: the flag being asked about, and the player’s own country from edge geolocation (see §5).

The catalog is five disjoint tiers concatenated into nested pools:

197+8=205197 + 8 = 205, 205+29=234205 + 29 = 234, and 234+3+5=242234 + 3 + 5 = 242. All 242 codes are distinct.

packages/shared/src/data/countries.ts
export const countries: Country[] = [
...sovereignCountries,
...constituentCountries,
...dependentTerritories,
...crownDependencies,
...partiallyRecognizedStates,
];
export const getCountryByCode = (code: string, pool: Country[] = countries) =>
pool.find((country) => country.code === code);
export const gameCountries: Country[] = [...sovereignCountries, ...constituentCountries];
export const gameCountriesWithExtendedTerritories: Country[] = [
...gameCountries,
...dependentTerritories,
];
export function resolveGameCountryPool(options?: {
includeExtendedTerritories?: boolean;
}): Country[] {
return options?.includeExtendedTerritories
? gameCountriesWithExtendedTerritories
: gameCountries;
}

Order matters for two reasons. The spread order fixes the index of each code in countries, which sets the cost of a linear lookup. It also fixes the order of poolCodes in the schedule file, which the --check compares index by index.

SurfacePoolExtended territories
Solo EasyEASY_CODES (30 codes)No
Solo MediumMEDIUM_CODES (76 codes)No
Solo HardresolveSoloGameCountryPool() → 205 or 234Opt-in via the includeExtendedTerritories setting
MultiplayergameCountries (default countryPool of generateQuestion)No
Daily challengeFrozen codes in v1-2026.jsonNo
Side challenges (Connections, Mystery, …)gameCountriesNo
Learn: Countries packgameCountriesNo
Learn: Territories packcrownDependencies + dependentTerritoriesStudy only
Explorer, /flag/*countriesBrowse only

The Solo gate lives in the web app:

apps/web/src/lib/components/game/game-mode.ts
export function effectiveIncludeExtendedTerritories(
difficulty: Difficulty,
includeExtendedTerritories: boolean
): boolean {
return difficulty === HARD_DIFFICULTY && includeExtendedTerritories;
}

The game server never reads this setting. docs/reference/country-pool-model.md lists that as an explicit non-goal: adding it would need a RoomSettings field, lobby UI, and a server generateQuestion pool argument.

The daily schedule snapshots the pool at generation time:

packages/shared/src/game-logic/daily-challenge-schedule-generator.ts
export function snapshotGameCountryCodes(): string[] {
return gameCountries.map((country) => country.code);
}
export function schedulePoolId(poolCodes: readonly string[]): string {
return `game-countries-${poolCodes.length}`;
}

At runtime the daily only reads days[utcDate]. poolCodes is an audit record: it lets --check detect that gameCountries changed after the file was written.

The generator locks elapsed days. For every date from launch up to the cutover (2026-06-18), it fetches dailyChallengeArchive:getDailyChallengeDayArchive from production Convex and keeps what players actually saw. Gaps fall back to derivePrngDailyCountryCodes. Only dates on or after the cutover are planned over the current pool. The committed file has 612 days from 2026-04-29 to 2027-12-31, five codes each.

generateDailySession uses the schedule when a date has exactly DAILY_ROUND_COUNT codes and otherwise falls back to the legacy PRNG generator:

packages/shared/src/game-logic/daily-challenge.ts
export const generateDailySession = (dateString: string): DailySession => {
const dailyCountryCodes = lookupDailyChallengeCountryCodes(dateString);
if (dailyCountryCodes) {
return buildDailySessionFromCodes(dateString, dailyCountryCodes);
}
return generateDailySessionFromPrng(dateString);
};

packages/shared/src/data/country-pool-model.test.ts (bun:test) encodes the model:

  • gameCountries codes equal sovereign ∪ constituent, with no duplicates.
  • Every dependentTerritories code is in countries and not in gameCountries.
  • gameCountriesWithExtendedTerritories.length === gameCountryCount + extendedDependentTerritoryCount, with no duplicates.
  • resolveGameCountryPool() returns 205 by default and 234 with the flag.
  • hardPoolCountryCount === gameCountryCount, so meta copy cites the default Hard count.
  • countryCount > gameCountryCount.

Run it with cd packages/shared && bun test src/data/country-pool-model.test.ts.

getCountryByCode scans from index 0. For a code at index ii it makes i+1i + 1 comparisons; a miss makes n=242n = 242. The default quiz codes occupy indices 0…2040 \ldots 204 (sovereign first, then constituent), so the expected cost for a uniformly chosen quiz code is

E[comparisons]=1205∑i=0204(i+1)=2062=103E[\text{comparisons}] = \frac{1}{205}\sum_{i=0}^{204}(i+1) = \frac{206}{2} = 103

Dependent territories sit at indices 205…233205 \ldots 233 and cost between 206 and 234 comparisons each.

The hot caller is option scoring in packages/shared/src/game-logic/game-logic.ts. For each question, every candidate in availableCountries goes through calculateOptionSimilarityScore, which calls getCountryByCode twice (once for the correct country, once for the candidate). With the default pool of 205, that is 204 candidates:

2×204×103≈4.2×104 string comparisons per question2 \times 204 \times 103 \approx 4.2 \times 10^{4} \text{ string comparisons per question}

That is small in absolute terms (it has not been profiled) and runs once per generated question. A Map built once at module load would make each lookup O(1)O(1). The daily schedule generator already does this (const countryByCode = new Map(countries.map(...))), and the server’s lookupCountryName repeats the linear scan with countries.find.

The schedule poolId is derived from length only. Swapping one sovereign code for another keeps poolId at game-countries-205, so a reviewer cannot rely on the id. The --check compares poolCodes element by element, which catches swaps and reorders:

match  ⟺  ∣A∣=∣B∣  ∧  ∀k:  Ak=Bk\text{match} \iff |A| = |B| \;\wedge\; \forall k:\; A_k = B_k

5. Threat model, failure modes & edge cases

Section titled “5. Threat model, failure modes & edge cases”
  • Geo codes outside the catalog. resolveVisitorCountryCode maps UK region NIR to GB-NIR, which is not in countries. The server’s lookupCountryName falls back to the raw code, so a Northern Ireland player’s country row is named GB-NIR. Any ISO code the edge returns that the catalog lacks behaves the same way. XX is dropped.
  • Case. The catalog is uppercase and getCountryByCode compares exactly, so getCountryByCode("us") returns undefined. The stats path upper-cases before lookup; flag URLs lowercase (fr.webp) and the opaque token lowercases before hashing.
  • Reclassifying a code. Moving a code between gameCountries and dependentTerritories changes the default quiz and the forward daily schedule. Treat it as a gameCountries change and run the full checklist in docs/reference/country-pool-model.md.
  • Past dailies are immutable. Regeneration keeps pre-cutover days from production archives. If Convex is unreachable, missing days are filled by the legacy PRNG, which may differ from what players saw. The script logs how many days came from each source.
  • Hardcoded counts. Meta, FAQ, and apps/web/static/llms.txt must use gameCountryCount / countryCount. A literal “205” in copy goes stale on the next pool edit.
  • Region vs continent. Seven codes (RU, TR, CY, KZ, AZ, AM, GE) have a slash-separated continent, for example Europe/Asia. Use getCountryContinents rather than string equality on continent.
ChoiceAlternativeWhy this one
String code as the only identityNumeric ids or slugsCodes are human-readable in logs, stable across stores, and already the flag file names.
Disjoint tier arrays concatenated into poolsOne array with a tier field and filtersPools are computed once at import; the tier is visible from which array a code lives in.
Frozen daily schedule with poolCodesDerive dailies from gameCountries at request timeA pool edit would silently change today’s and past puzzles. The snapshot makes the change explicit and reviewable.
Extended territories as Solo Hard opt-in onlyInclude them everywherePlayers objected to obscure territory flags in daily and Hard (2026-06 decision). Shared modes stay predictable.
Linear find in getCountryByCodeMap indexSimple and fast enough at 242 entries. A Map is a small change if profiling shows option generation matters.

Non-goals: host-controlled territory opt-in for multiplayer rooms, territories in the daily, and a numeric country id.