Country identity and pool model
1. Foundational mental model
Section titled “1. Foundational mental model”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.
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:
, , and . All 242 codes are distinct.
2. Implementation status & known gaps
Section titled “2. Implementation status & known gaps”3. Concrete implementation
Section titled “3. Concrete implementation”The pool definitions
Section titled “The pool definitions”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.
Which surface uses which pool
Section titled “Which surface uses which pool”| Surface | Pool | Extended territories |
|---|---|---|
| Solo Easy | EASY_CODES (30 codes) | No |
| Solo Medium | MEDIUM_CODES (76 codes) | No |
| Solo Hard | resolveSoloGameCountryPool() → 205 or 234 | Opt-in via the includeExtendedTerritories setting |
| Multiplayer | gameCountries (default countryPool of generateQuestion) | No |
| Daily challenge | Frozen codes in v1-2026.json | No |
| Side challenges (Connections, Mystery, …) | gameCountries | No |
| Learn: Countries pack | gameCountries | No |
| Learn: Territories pack | crownDependencies + dependentTerritories | Study only |
Explorer, /flag/* | countries | Browse only |
The Solo gate lives in the web app:
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.
Schedule alignment
Section titled “Schedule alignment”The daily schedule snapshots the pool at generation time:
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:
export const generateDailySession = (dateString: string): DailySession => { const dailyCountryCodes = lookupDailyChallengeCountryCodes(dateString); if (dailyCountryCodes) { return buildDailySessionFromCodes(dateString, dailyCountryCodes); } return generateDailySessionFromPrng(dateString);};Regression test
Section titled “Regression test”packages/shared/src/data/country-pool-model.test.ts (bun:test) encodes the model:
gameCountriescodes equal sovereign ∪ constituent, with no duplicates.- Every
dependentTerritoriescode is incountriesand not ingameCountries. 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.
4. Internal mechanics & mathematics
Section titled “4. Internal mechanics & mathematics”Linear lookup cost
Section titled “Linear lookup cost”getCountryByCode scans from index 0. For a code at index it makes comparisons; a miss makes . The default quiz codes occupy indices (sovereign first, then constituent), so the expected cost for a uniformly chosen quiz code is
Dependent territories sit at indices 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:
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 . 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.
Pool-change arithmetic
Section titled “Pool-change arithmetic”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:
5. Threat model, failure modes & edge cases
Section titled “5. Threat model, failure modes & edge cases”- Geo codes outside the catalog.
resolveVisitorCountryCodemaps UK regionNIRtoGB-NIR, which is not incountries. The server’slookupCountryNamefalls back to the raw code, so a Northern Ireland player’s country row is namedGB-NIR. Any ISO code the edge returns that the catalog lacks behaves the same way.XXis dropped. - Case. The catalog is uppercase and
getCountryByCodecompares exactly, sogetCountryByCode("us")returnsundefined. 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
gameCountriesanddependentTerritorieschanges the default quiz and the forward daily schedule. Treat it as agameCountrieschange and run the full checklist indocs/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.txtmust usegameCountryCount/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-separatedcontinent, for exampleEurope/Asia. UsegetCountryContinentsrather than string equality oncontinent.
6. Architectural tradeoffs & non-goals
Section titled “6. Architectural tradeoffs & non-goals”| Choice | Alternative | Why this one |
|---|---|---|
| String code as the only identity | Numeric ids or slugs | Codes are human-readable in logs, stable across stores, and already the flag file names. |
| Disjoint tier arrays concatenated into pools | One array with a tier field and filters | Pools are computed once at import; the tier is visible from which array a code lives in. |
Frozen daily schedule with poolCodes | Derive dailies from gameCountries at request time | A 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 only | Include them everywhere | Players objected to obscure territory flags in daily and Hard (2026-06 decision). Shared modes stay predictable. |
Linear find in getCountryByCode | Map index | Simple 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.