Skip to content

Mystery deduction engine

Status
Production
Verified
against master

Mystery (/challenges/mystery) hides one country and describes its flag in words. The player gets one hint on Start and one more after each wrong guess. Every guess also shows the same color, pattern and region feedback as the Daily, from calculateSimilarityScore. The game ends on a correct guess or after MAX_GUESSES (8).

The engine has two parts:

  1. Hint generation (mystery-hints.ts). getHintsForCountry(code) collects every hint that applies to the flag, drops redundant ones, sorts them by tier, shuffles within each tier, and keeps the first MAX_FLAG_HINTS (8).
  2. Reveal loop (MysteryChallenge.svelte). A counter hintsRevealed starts at 1 and grows by one per wrong guess, capped at the number of hints the flag has.

Tiers go from broad to specific:

TierMeaningHint types (sample)
0Palettecolors, colorCount
1LayouthorizontalTricolor, verticalTricolor, stripes, noSymbols, redDominant
2Structural devicescanton, nordicCross, saltire, stars, crescent, triangle, sun
3Specific marks and shapeemblem, text, map, eagles, lions, weapons, shields, squareFlag, ratio12

France, for example, gets colors, noSymbols and verticalTricolor: one tier-0 hint and two tier-1 hints.

Mystery is live and fully client-side. There is no server check, no seed in the URL, and no scoring beyond the counts shown on the result screen (guesses used and hints seen).

Hint coverage varies a lot by country. Over the 205 countries in gameCountries:

Hints available345678
Countries275355411811

Only 11 countries reach the cap of eight. For the other 194 the hint panel stops growing before the guesses run out: a country with three hints has no new hint to offer from the fourth guess onward.

apps/web/src/routes/challenges/mystery/MysteryChallenge.svelte
const isCorrect = guessCountry.code === targetCountry.code;
const newGuesses = [...guesses, guessCountry];
const gameOver = isCorrect || newGuesses.length >= MAX_GUESSES;
guesses = newGuesses;
lastSubmittedGuessCode = guessCountry.code;
if (isCorrect) {
void playChallengeWinSound();
} else {
frameShake.trigger();
void playChallengeErrorSound();
}
if (!isCorrect) {
hintsRevealed = Math.min(hintsRevealed + 1, allHints.length);
}

handleStart sets hintsRevealed = 1. The start screen copy matches: “Press Start to reveal the first hint. You have 8 guesses. Each wrong guess reveals another hint.”

apps/web/src/routes/challenges/mystery/mystery-hints.ts
function mixSeed(seed: number, tierIndex: number): number {
return (seed ^ ((tierIndex + 1) * 0x9e37_79b9)) & RNG_M;
}
/** Broad-tier-first order; Fisher–Yates inside each tier using `mixSeed(seed, tier)`. */
function orderMysteryHintsByTier(hints: MysteryHint[], seed: number): MysteryHint[] {
const buckets: MysteryHint[][] = Array.from({ length: HINT_TIER_COUNT }, () => []);
for (const hint of hints) {
const tier = Math.min(getMysteryHintTier(hint.type), HINT_TIER_COUNT - 1);
buckets[tier]!.push(hint);
}

Because the cap is applied after ordering, a flag with many tier-3 marks loses its most specific hints first. The comment above HINT_TIER_BY_TYPE describes this order: broad hints that split the space come first.

apps/web/src/routes/challenges/mystery/mystery-hints.ts
const RNG_A = 1103515245;
const RNG_C = 12345;
const RNG_M = 0x7f_ff_ff_ff;
/** Seeded shuffle (Fisher–Yates) for reproducible ordering when a seed is provided. */
function seededShuffle<T>(array: T[], seed: number): T[] {
const shuffled = [...array];
let state = seed;
for (let index = shuffled.length - 1; index > 0; index--) {
state = (state * RNG_A + RNG_C) & RNG_M;
const randomIndex = state % (index + 1);

dedupeRedundantFlagHints removes colorCount when verticalTricolor, horizontalTricolor or bicolorVertical is present, since those layouts already state how many colors there are.

The LCG step is meant to compute s′=(as+c) mod 231s' = (a s + c) \bmod 2^{31} with a=1103515245a = 1103515245 and c=12345c = 12345. JavaScript evaluates state * RNG_A as a double. With ss close to 2312^{31} the product is about

a⋅s≈1.1×109⋅2.1×109≈2.4×1018≫253≈9.0×1015,a \cdot s \approx 1.1 \times 10^{9} \cdot 2.1 \times 10^{9} \approx 2.4 \times 10^{18} \gg 2^{53} \approx 9.0 \times 10^{15},

so the low bits are rounded away before & RNG_M runs. At that magnitude the spacing between doubles is 292^{9} or more, so adding cc is lost too and the result is almost always even. Exact arithmetic only survives when as<253a s < 2^{53}, which means s<253/a≈8.2×106s < 2^{53}/a \approx 8.2 \times 10^{6}, about 0.4% of seeds.

The first randomIndex is state % 2 for a two-item tier, which is 0 nearly every time, so the pair is swapped. Measured over 100,000 random seeds against an exact BigInt version of the same LCG:

Tier sizeJavaScript seededShuffleExact LCG
2reversed 99.8%, original 0.2%50.1% / 49.9%
33 of 6 orders, about 33% eachall 6, about 16.7% each
43 orders near 33%, 16 seen in total12 of 24 orders, about 8.4% each

Even the exact version reaches only 12 of 24 orders for four items. The modulus is a power of two, and the low bits of such an LCG have short periods, so state % (index + 1) for small index is poorly mixed.

For France, the tier-1 pair noSymbols and verticalTricolor is a two-item tier, so almost every game shows them in the same order after the tier-0 colors hint.

Let HH be the number of hints for the target and ww the number of wrong guesses so far. Hints on screen:

h(w)=min⁡(1+w, H),0≤w≤7.h(w) = \min(1 + w,\ H), \qquad 0 \le w \le 7.

With the distribution in section 2, the expected hint count is

E[H]=3⋅27+4⋅53+5⋅55+6⋅41+7⋅18+8⋅11205=1028205≈5.01,\mathbb{E}[H] = \frac{3 \cdot 27 + 4 \cdot 53 + 5 \cdot 55 + 6 \cdot 41 + 7 \cdot 18 + 8 \cdot 11}{205} = \frac{1028}{205} \approx 5.01,

so on average the hint panel is complete by the fifth guess and the last three guesses rely on similarity feedback alone.

5. Threat model, failure modes & edge cases

Section titled “5. Threat model, failure modes & edge cases”
  • Answer exposure. The target country and its hints live in component state. Anyone with devtools can read them. Mystery awards no verified reward, so this is accepted.
  • Hints that fit many countries. A three-hint flag such as a plain tricolor may still match several countries after all hints are shown. The similarity feedback on each guess is what separates them.
  • Unknown code. getHintsForCountry returns [] for a code missing from COUNTRIES_BY_CODE. hintsRevealed then stays capped at 0 after the first wrong guess, and the game still runs on similarity feedback alone.
  • Same order every time. Because of the shuffle bias, repeat players see the same within-tier order for most small tiers. This makes the hint panel more predictable than the code comment (“so runs are not identical every time”) intends.
  • Repeated targets. No history is kept, so the same country can be drawn in consecutive games with probability 1/2051/205.
DecisionBenefitCost
Tiered reveal, broad firstEarly hints split the pool. Later hints confirm.Countries with few hints give nothing new late in the game.
One hint per wrong guess, no buttonSimple loop. No hint economy to balance.The player cannot trade a guess for information.
No score decayEvery win counts the same. Result screen still shows guesses and hints.No reward for solving early beyond the share text.
Hand-rolled LCG shuffleNo dependency. Seedable in tests.Precision loss makes small tiers nearly fixed.
Fully client-sideNo backend, instant startAnswers are inspectable. No shared puzzle.

Non-goals: daily or shared Mystery puzzles, free-text hints, and difficulty levels.