Daily challenge engine
1. Foundational mental model
Section titled “1. Foundational mental model”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:
- 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. - How guesses are graded.
calculateSimilarityScore(guess, target)runs in the browser for every guess. - 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 mutationawardDailyChallengeXpredeems 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).
2. Implementation status & known gaps
Section titled “2. Implementation status & known gaps”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.
3. Concrete implementation
Section titled “3. Concrete implementation”Picking the session
Section titled “Picking the session”generateDailySession is a two-line dispatcher. The lookup only accepts an entry with exactly DAILY_ROUND_COUNT codes.
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:
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;Generating the schedule
Section titled “Generating the schedule”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:
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:
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.
Per-guess feedback
Section titled “Per-guess feedback”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:
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.
Validation and the completion token
Section titled “Validation and the completion token”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.
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:
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).
Redeeming the token
Section titled “Redeeming the token”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:
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.
4. Internal mechanics & mathematics
Section titled “4. Internal mechanics & mathematics”The XP formula as coded
Section titled “The XP formula as coded”Let be the updated streak, the total guess count, rounds solved and total rounds (5). computeDailyChallengeXpBreakdown sums four terms and caps the result:
- 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. - 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.
- 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. - is the effort bonus, with and :
A perfect five-for-five first-try day on streak 7 earns , plus 25 if the surprise fires.
Why the cooldown saturates the easy band
Section titled “Why the cooldown saturates the easy band”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 easy codes are blocked, leaving
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 times (measured: 37 or 38). Medium keeps at least candidates and hard at least .
Why the hash ordering is nearly alphabetical
Section titled “Why the hash ordering is nearly alphabetical”scheduleHash evaluates a base-31 polynomial mod , with the code as the last characters of the key. Write the key as a prefix (version, date and slot) followed by a two-letter code . Then
The first term is the same for every candidate in a slot. The code term for uppercase letters lies in , a window of 800. Sorting by is the same as sorting by unless the shared offset lands within 800 of , which happens with probability about . And ordering by 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.
Round 1
Easy

Australia
AU
Round 2
Easy

Brazil
BR
Round 3
Medium

Austria
AT
Round 4
Medium

Croatia
HR
Round 5
Hard

Andorra
AD
5. Threat model, failure modes & edge cases
Section titled “5. Threat model, failure modes & edge cases”| Scenario | What happens |
|---|---|
| Reading answers from the bundle | Possible. v1-2026.json and the PRNG are client-side. Validation checks legality, not secrecy. |
| Forged completion | Rejected. The token needs an HMAC over the payload with BACKEND_CONVEX_SHARED_SECRET. |
| Replaying one token | Rejected. The signature hash goes into gameTokens; a second redemption returns alreadyClaimed. |
| New token for the same date | Rejected by hasXpAwardAlready(context, userId, "daily", date). |
| Token redeemed after 10 minutes | Returns token_expired. |
| Secret rotation | Verification tries the primary secret then BACKEND_CONVEX_SHARED_SECRET_PREV. |
| Claiming yesterday after today | The validator accepts yesterday. resolveStreakUpdate(today, yesterday) sees a gap of −1, which is not 1, so the streak resets to 1. |
| Midnight rollover mid-session | A session started before UTC midnight can still be submitted for its date for the next 24 hours, because yesterday is accepted. |
| Schedule missing a date | Falls back to the PRNG silently. For 2028 dates that means repeated sessions (see section 2). |
6. Architectural tradeoffs & non-goals
Section titled “6. Architectural tradeoffs & non-goals”| Decision | Benefit | Cost |
|---|---|---|
| Committed JSON schedule | Reviewable diffs, stable archive, identical output on web, server and MCP | Answers ship to the client. Pool changes need a regeneration step. |
| Deterministic hash order in the DFS | Reproducible file from code alone | Near-alphabetical order leaves 124 pool codes unused in forward days. |
| PRNG fallback | The game never has an empty day | Date-sum seed collides heavily after the schedule ends. |
| Web signs, Convex redeems | Convex 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 speed | Rewards showing up daily | A 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.