Skip to content

The Daily (/daily, with /challenges/daily kept as a legacy redirect) gives every player the same five flags for one UTC day. For each flag the player types up to four country guesses. Each wrong guess comes back with three signals (color, pattern, region), each graded match, partial or none, plus an overall similarity percentage.

Three pieces cooperate:

  1. Which flags. generateDailySession(date) looks the date up in a committed JSON schedule. If the date is missing, it falls back to a deterministic PRNG seeded from the date.
  2. How guesses are graded. calculateSimilarityScore(guess, target) runs in the browser for every guess.
  3. What counts. When the session ends, the client posts its rounds to /api/challenges/daily/validate. The server replays the same session, checks the submission is legal, and for signed-in players returns an HMAC-signed completion token. The Convex mutation awardDailyChallengeXp redeems that token once, updates the streak and awards XP.

The day key is always a UTC calendar date. getTodayDateString formats with the en-CA locale in the UTC time zone, so a player in Tokyo and a player in Los Angeles roll over at the same instant (UTC midnight, not local midnight).

The Daily is live. The schedule file v1-2026.json holds 612 days, from the launch date 2026-04-29 to 2027-12-31, with five codes per day and a poolId of game-countries-205. Days before the cutover 2026-06-18 reproduce the old PRNG output so archive links stay stable. Days from the cutover onward (562 of them) come from the constraint generator.

Measured over the 562 forward days, the generator meets every hard rule: no code repeats within 15 days, every day spans at least three continents, and no day has more than one constituent-style code. Coverage is lopsided, though. Only 81 of the 205 pool codes ever appear. 81 of the 99 hard codes and 43 of the 76 medium codes never show up, and the hard codes that do appear all sort between AD and CD. Section 4 explains why.

generateDailySession is a two-line dispatcher. The lookup only accepts an entry with exactly DAILY_ROUND_COUNT codes.

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);
};

The fallback PRNG is the classic sin hash, seeded from the date parts:

packages/shared/src/game-logic/daily-challenge.ts
const seededRandom = (seed: number): number => {
const value = Math.sin(seed) * 10000;
return value - Math.floor(value);
};
const getDateSeed = (dateString: string): number =>
dateString
.split("-")
.reduce((total, datePart) => total + Number.parseInt(datePart, 10), 0) * 1337;

packages/shared/scripts/generate-daily-challenge-schedule.ts calls buildScheduleFile, which keeps pre-cutover days as PRNG output and fills forward days with generateForwardDayCodes. That function first tries pickDayCodes with the soft constraints on, and retries with them off if the strict search fails.

pickDayCodes is a depth-first search over the five slots. For each slot it filters the pool by band, cooldown and “not already used today”, then sorts candidates by scheduleHash:

packages/shared/src/game-logic/daily-challenge-schedule-generator.ts
export function scheduleHash(
version: number,
utcDate: string,
slot: number,
code: string
): number {
let hash = version * 1_000_003;
const key = `${utcDate}:${slot}:${code}`;
for (let i = 0; i < key.length; i++) {
hash = (hash * 31 + key.charCodeAt(i)) >>> 0;
}
return hash;
}

A complete day is accepted only if it passes the soft constraints:

packages/shared/src/game-logic/daily-challenge-schedule-generator.ts
function passesSoftConstraints(codes: string[]): boolean {
const continents = new Set(codes.map((code) => continentForCode(code)));
if (continents.size < 3) {
return false;
}
const constituentCount = codes.filter((code) => CONSTITUENT_CAP_CODES.has(code)).length;
return constituentCount <= 1;
}

CONSTITUENT_CAP_CODES contains GB-ENG, GB-SCT, GB-WLS, GL, FO, AW, CW and SX.

DailyChallenge.svelte calls calculateSimilarityScore(country, correctCountry) for each guess, and a round ends when the guess is correct or newGuesses.length >= DAILY_MAX_GUESSES. The three signals use these thresholds:

packages/shared/src/utils/flag-similarity-score.ts
const COLOR_MATCH_SCORE = 30;
const PATTERN_MATCH_SCORE = 40;
const REGION_MATCH_SCORE = 30;
const COLOR_MATCH_THRESHOLD = 0.85;
const COLOR_PARTIAL_THRESHOLD = 0.45;
const PATTERN_MATCH_THRESHOLD = 0.7;
const PATTERN_PARTIAL_THRESHOLD = 0.35;

Region is match for the same region and partial for the same continent. A wrong guess is capped at 95 if the pair is listed in SIMILAR_FLAGS, otherwise 85. Only the correct country returns overallScore: 100.

validateRound rejects, in order: no guesses, more than DAILY_MAX_GUESSES, duplicates, unknown codes, a solved flag that disagrees with the last guess, and a loss with fewer than four guesses.

apps/web/src/routes/api/challenges/daily/lib/daily-session-validate.ts
if (roundSolved !== wasCorrect) {
return { error: `round_${roundIndex}_solved_mismatch`, solved: false };
}
if (!wasCorrect && roundGuesses.length < DAILY_MAX_GUESSES) {
return { error: `round_${roundIndex}_incomplete_loss`, solved: false };
}

For a signed-in player with claimXp, the route signs a token with BACKEND_CONVEX_SHARED_SECRET:

apps/web/src/routes/api/challenges/daily/lib/daily-session-validate.ts
resultPattern: params.resultPattern,
expiresAt: Date.now() + 10 * 60 * 1000,
nonce: crypto.randomUUID(),
completedAt: params.completedAt,
};
const encodedPayload = base64urlEncode(JSON.stringify(payload));
const sig = await hmacSign(encodedPayload, params.secret);
return base64urlEncode(JSON.stringify({ encodedPayload, sig }));

resultPattern is a bitmask with bit 1 << index set for each solved round (daily-result-pattern.ts).

awardDailyChallengeXp checks, in order: authenticated user, secret configured, HMAC valid (against the primary secret and BACKEND_CONVEX_SHARED_SECRET_PREV), userId matches, not expired, and the token hash not yet in gameTokens (by_token_hash index). It then checks hasXpAwardAlready(context, userId, "daily", date) and resolves the streak:

apps/web/src/convex/lib/xp.ts
if (lastStreakDate === todayDate) {
return { newStreakDays: 0, streakAction: "already_done" };
}
const last = new Date(lastStreakDate);
const today = new Date(todayDate);
const diffDays = Math.round((today.getTime() - last.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 1) {
return { newStreakDays: 1, streakAction: "increment" };
}
return { newStreakDays: 1, streakAction: "reset" };

On increment the mutation adds 1 to the stored dailyStreakCount. On reset the streak becomes 1.

Let ss be the updated streak, gg the total guess count, rr rounds solved and RR total rounds (5). computeDailyChallengeXpBreakdown sums four terms and caps the result:

XP=min⁡(round⁡(B(s)+E+M(s)+S), 400)\text{XP} = \min\Big(\operatorname{round}\big(B(s) + E + M(s) + S\big),\ 400\Big)
  • B(s)B(s) is the tier base from XP_DAILY_STREAK_TIERS: 100 from day 1, 130 from day 7, 160 from day 30, 200 from day 100.
  • M(s)M(s) is a one-time burst on exact milestone days: 25, 50, 75, 100, 150, 200, 300, 400, 500 at streak 3, 7, 14, 21, 30, 60, 90, 180, 365.
  • SS is 25 when an FNV-1a hash of `${userId}:${date}:daily-surprise` maps below 0.05, else 0. The roll is deterministic per user and date, so retrying cannot reroll it.
  • EE is the effort bonus, with gmin⁡=R=5g_{\min} = R = 5 and gmax⁡=4R=20g_{\max} = 4R = 20:
E=min⁡(20⋅[won∧r≥R]+round⁡(40⋅clamp⁡[0,1] ⁣(1−g−520−5)), 60)E = \min\left(20\cdot[\text{won} \wedge r \ge R] + \operatorname{round}\left(40 \cdot \operatorname{clamp}_{[0,1]}\!\left(1 - \frac{g - 5}{20 - 5}\right)\right),\ 60\right)

A perfect five-for-five first-try day on streak 7 earns 130+60+50=240130 + 60 + 50 = 240, plus 25 if the surprise fires.

Two slots per day draw from 30 easy codes, and a code used on any of the previous 14 days is excluded. On a given day at most 2×14=282 \times 14 = 28 easy codes are blocked, leaving

30−2⋅14=230 - 2 \cdot 14 = 2

candidates, exactly the two the day needs. The easy band is therefore fully determined once it settles into a 15-day cycle, and each easy code appears about 562⋅2/30≈37.5562 \cdot 2 / 30 \approx 37.5 times (measured: 37 or 38). Medium keeps at least 76−28=4876 - 28 = 48 candidates and hard at least 99−14=8599 - 14 = 85.

Why the hash ordering is nearly alphabetical

Section titled “Why the hash ordering is nearly alphabetical”

scheduleHash evaluates a base-31 polynomial mod 2322^{32}, with the code as the last characters of the key. Write the key as a prefix pp (version, date and slot) followed by a two-letter code c1c2c_1 c_2. Then

h=(H(p)⋅312+31 c1+c2) mod 232.h = \big(H(p)\cdot 31^{2} + 31\,c_1 + c_2\big) \bmod 2^{32}.

The first term is the same for every candidate in a slot. The code term g=31c1+c2g = 31c_1 + c_2 for uppercase letters lies in [2080,2880][2080, 2880], a window of 800. Sorting by hh is the same as sorting by gg unless the shared offset lands within 800 of 2322^{32}, which happens with probability about 800/232≈1.9×10−7800 / 2^{32} \approx 1.9 \times 10^{-7}. And ordering by 31c1+c231c_1 + c_2 is lexicographic order of the code.

So the depth-first search tries candidates in alphabetical order every day. Medium and hard slots take the first codes that are off cooldown and satisfy the continent rule, which is why only AD through CD appear in the hard slot. The date shifts every candidate’s hash by the same amount, so their order stays fixed.

Interactive

Daily schedule explorer

Reads the committed v1-2026 schedule, the same file the game uses after the cutover. Bands are the slot the generator drew from.

  1. Round 1

    Easy

    Australia

    Australia

    AU

  2. Round 2

    Easy

    Brazil

    Brazil

    BR

  3. Round 3

    Medium

    Austria

    Austria

    AT

  4. Round 4

    Medium

    Croatia

    Croatia

    HR

  5. Round 5

    Hard

    Andorra

    Andorra

    AD

After 2026-06-18, 81 of 205 pool codes appear across 562 days. The hard slot uses 18: AD AF AG AL AM AW AZ BA BB BF BH BI BJ BN BS BT BZ CD.

5. Threat model, failure modes & edge cases

Section titled “5. Threat model, failure modes & edge cases”
ScenarioWhat happens
Reading answers from the bundlePossible. v1-2026.json and the PRNG are client-side. Validation checks legality, not secrecy.
Forged completionRejected. The token needs an HMAC over the payload with BACKEND_CONVEX_SHARED_SECRET.
Replaying one tokenRejected. The signature hash goes into gameTokens; a second redemption returns alreadyClaimed.
New token for the same dateRejected by hasXpAwardAlready(context, userId, "daily", date).
Token redeemed after 10 minutesReturns token_expired.
Secret rotationVerification tries the primary secret then BACKEND_CONVEX_SHARED_SECRET_PREV.
Claiming yesterday after todayThe validator accepts yesterday. resolveStreakUpdate(today, yesterday) sees a gap of −1, which is not 1, so the streak resets to 1.
Midnight rollover mid-sessionA session started before UTC midnight can still be submitted for its date for the next 24 hours, because yesterday is accepted.
Schedule missing a dateFalls back to the PRNG silently. For 2028 dates that means repeated sessions (see section 2).
DecisionBenefitCost
Committed JSON scheduleReviewable diffs, stable archive, identical output on web, server and MCPAnswers ship to the client. Pool changes need a regeneration step.
Deterministic hash order in the DFSReproducible file from code aloneNear-alphabetical order leaves 124 pool codes unused in forward days.
PRNG fallbackThe game never has an empty dayDate-sum seed collides heavily after the schedule ends.
Web signs, Convex redeemsConvex never replays game logic. Tokens are single-use.Two services must share a secret and agree on clock and date rules.
XP on a streak, not on speedRewards showing up dailyA missed day costs the whole tier.

Non-goals: hiding the answer from a determined client, per-timezone days, and server-side grading of individual guesses.