Blur and Spotlight reveals
1. Foundational mental model
Section titled “1. Foundational mental model”Blur and Spotlight are two ways of rationing information about one flag. Both routes (/challenges/blur, /challenges/spotlight) pick a target with pickRandomCountry() from gameCountries, give the player 5 guesses through CountrySearch, and reveal more of the flag as the round goes on.
- Blur reveals by time: every wrong guess lowers a CSS
blur()radius and agrayscale()percentage. The player spends guesses to buy sharpness. - Spotlight reveals by place: the flag sits under a black canvas, and each tap erases a circle. The player spends moves (a separate budget from guesses) to buy area.
2. Implementation status & known gaps
Section titled “2. Implementation status & known gaps”3. Concrete implementation
Section titled “3. Concrete implementation”Blur schedule
Section titled “Blur schedule”The whole schedule is one pure function:
export const BLUR_REVEAL = { initialBlurPx: 26, blurReducePerGuess: 5, initialGrayscale: 100, grayscaleReducePerGuess: 17,} as const;
export function blurRevealAtGuessIndex(guessIndex: number): BlurRevealFilter { return { blurPx: Math.max( 0, BLUR_REVEAL.initialBlurPx - guessIndex * BLUR_REVEAL.blurReducePerGuess ), grayscale: Math.max( 0, BLUR_REVEAL.initialGrayscale - guessIndex * BLUR_REVEAL.grayscaleReducePerGuess ), };}BlurChallenge.svelte feeds it the guess count and forces both values to 0 once the round ends, win or lose:
const guessCount = $derived(guesses.length);const blurAmount = $derived( isCompleted ? 0 : blurRevealAtGuessIndex(guessCount).blurPx);const grayscaleAmount = $derived( isCompleted ? 0 : blurRevealAtGuessIndex(guessCount).grayscale);Snapshot crossfade in BlurFlagHero
Section titled “Snapshot crossfade in BlurFlagHero”BlurFlagHero never transitions filter. It keeps the current filter in snapshotBlur. When the props change, it copies the old snapshot to outgoingBlur, applies the new filter to the base layer immediately, and lets a second layer with the old filter fade out:
function beginRevealCrossfade(from: BlurFilterSnapshot, to: BlurFilterSnapshot) { clearRevealFadeTimer(); outgoingBlur = from; snapshotBlur = to; revealOutgoingKey += 1; revealFadeTimer = setTimeout(() => { revealFadeTimer = undefined; outgoingBlur = null; }, BLUR_REVEAL_CROSSFADE_MS);}The outgoing layer is keyed by revealOutgoingKey, so a fast second guess restarts the fade from the latest snapshot instead of stacking layers. A new round (gameRoundId changes) resets snapshotBlur with no fade. The layer’s only animation is opacity:
{#if outgoingBlur} {#key revealOutgoingKey} <div class="blur-flag-reveal-outgoing pointer-events-none absolute inset-0 z-1 flex items-center justify-center p-2 sm:p-4 isolate" style:filter={filterStyle(outgoingBlur)} style:--blur-reveal-ms="{BLUR_REVEAL_CROSSFADE_MS}ms" style:--blur-reveal-easing={BLUR_REVEAL_EASING} aria-hidden="true" >Both layers live inside a contain-[paint] wrapper, and the hero sits inside ElementsClickProtection without allowInteraction, which adds a transparent z-50 overlay plus contextmenu, dragstart, and selectstart blockers. That stops “Save image as” and long-press callouts on the sharp source image. It does nothing about DevTools.
Interactive
Blur reveal frame-time benchmark
Steps the same flag through the production blur schedule two ways and samples requestAnimationFrame deltas for each. Strategies run one after the other so they do not share frames.
transition: filter 500ms
0 guesses: blur 26px, grayscale 100%
- p50 frame
- –
- p95 frame
- –
- Dropped
- –
static filters, opacity 1 → 0 in 500ms
0 guesses: blur 26px, grayscale 100%
- p50 frame
- –
- p95 frame
- –
- Dropped
- –
Production schedule blurRevealAtGuessIndex()
- 0 guesses 26px 100%
- 1 guess 21px 83%
- 2 guesses 16px 66%
- 3 guesses 11px 49%
- 4 guesses 6px 32%
- Solved 0px 0%
Spotlight difficulty table
Section titled “Spotlight difficulty table”const DIFFICULTY_SETTINGS = { easy: { name: "Easy", radius: 50, maxMoves: 15 }, medium: { name: "Medium", radius: 25, maxMoves: 10 }, hard: { name: "Hard", radius: 22, maxMoves: 3 },} as const satisfies Record<DifficultyLevel, DifficultyConfig>;Changing difficulty starts a new game (onSelect calls startNewGame). The default is medium.
Canvas mask
Section titled “Canvas mask”The canvas is 600×400 in its own coordinate space and stretched to the 3:2 frame with CSS. Every render repaints the whole mask: fill black, clip to the flag’s letterboxed rectangle, erase one circle per reveal, then stroke a faint outline:
ctx.clearRect(0, 0, w, h);repaintBase(ctx, w, h);
if (activeReveals.length > 0) { ctx.save(); if (rect) { ctx.beginPath(); ctx.rect(rect.x, rect.y, rect.w, rect.h); ctx.clip(); }
ctx.globalCompositeOperation = "destination-out"; for (const point of activeReveals) { ctx.beginPath(); ctx.arc(point.x, point.y, radius, 0, Math.PI * 2); ctx.fill(); }The fill is a solid color, so each hole has a hard edge. There is no radial gradient or feathering. The flag itself is a plain <img> behind the canvas (z-0, canvas at z-2), and a black div covers it until the first render sets isMaskBufferPrimed.
Click rejection
Section titled “Click rejection”const liveFlagRect = getLiveFlagRect(canvas);if (!isPointInsideRect({ x: clickX, y: clickY }, liveFlagRect)) { onInvalidClick(); return;}
const isTooClose = reveals.some( (point) => Math.sqrt((point.x - clickX) ** 2 + (point.y - clickY) ** 2) < revealRadius / 2);
if (!isTooClose) { onReveal({ x: clickX, y: clickY });}A click outside the flag shakes the frame and costs nothing. A click whose center is closer than to any earlier reveal is dropped with no feedback and costs nothing. Everything else spends one move.
Per-guess score
Section titled “Per-guess score”guesses={guesses.map((guess, index) => ({ code: guess.code, score: guess.code === tc.code ? 100 : Math.max(0, 100 - (index + 1) * 15), status: guess.code === tc.code ? "correct" : "incorrect",}))}Wrong guesses at positions 1 to 5 would score 85, 70, 55, 40, 25. As noted in §2, nothing downstream reads this field. The result screen shows moves used, guesses, and difficulty instead.
4. Internal mechanics & mathematics
Section titled “4. Internal mechanics & mathematics”Blur schedule
Section titled “Blur schedule”For wrong guesses so far, with , , , :
The round ends after 5 guesses, so play only ever sees :
| Wrong guesses | 0 | 1 | 2 | 3 | 4 | Round over |
|---|---|---|---|---|---|---|
blur (px) | 26 | 21 | 16 | 11 | 6 | 0 |
grayscale (%) | 100 | 83 | 66 | 49 | 32 | 0 |
| Visibility badge | 0% | 20% | 40% | 60% | 80% | 100% |
The max(0, …) floor never fires in play: blur would first go negative at , grayscale at too. The last hint the player sees is still a 6 px blur at 32% grayscale. The badge is , a guess counter with a percent sign, and it does not measure perceived sharpness.
Why a crossfade
Section titled “Why a crossfade”With strategy A (transition: filter), every frame of the 500 ms transition has a different blur radius, so the engine must re-run the blur over the whole layer on each frame. CSS blur(σ) is a Gaussian with standard deviation σ; how expensive one pass is depends on the engine (separable kernels, box-blur approximations, downsampling), but the work repeats roughly 30 times at 60 Hz.
With strategy B, neither layer’s filter changes during the fade. The only animated property is the outgoing layer’s opacity, which browsers can hand to the compositor. At worst the engine re-runs two fixed blurs per frame; when it caches the filtered layers, it only blends two textures. The on-screen result during the fade is a blend,
where A would show for intermediate radii. At a 5 px step between two already-soft images the difference is hard to see. The code comment on BLUR_REVEAL_CROSSFADE_MS states the intent; the benchmark above lets you measure it on your own hardware, and the result will vary.
Spotlight coordinates and flag rectangle
Section titled “Spotlight coordinates and flag rectangle”Pointer positions map to canvas units independently of display size:
So radius, rejection distance, and coverage are all resolution-independent. On a 390 px phone the 25-unit Medium radius is about 16 CSS px on screen, but it covers the same fraction of the flag as on desktop.
The flag is letterboxed with object-contain. For image aspect , getFlagRectForImage returns when and otherwise, centered. Reveals are clipped to that rectangle .
Coverage
Section titled “Coverage”After reveals at centers with radius , the visible area is
Upper bound: , reached only when no two disks overlap and none crosses the edge of . Lower bound for : every center lies inside , so at least a quarter disk is visible, (when both sides of are at least ).
The rejection rule makes centers pairwise at least apart. Two disks at the minimum distance overlap in a lens of area
so the second disk adds only , about 31% of a fresh disk. The rule stops free duplicate taps; it does not stop wasteful ones.
For a 3:2 flag, units², and the best case is:
| Difficulty | Moves | One disk / | Max coverage | ||
|---|---|---|---|---|---|
| Easy | 50 | 15 | 7,854 | 3.27% | 49.1% |
| Medium | 25 | 10 | 1,963 | 0.82% | 8.2% |
| Hard | 22 | 3 | 1,521 | 0.63% | 1.9% |
Flags that letterbox to a smaller rectangle raise the fraction. A square flag such as Switzerland letterboxes to 400×400 (160,000 units²), so Easy tops out at 73.6%. Hard differs from Medium mostly in its 3-move cap; the radius only shrinks from 25 to 22.
5. Threat model, failure modes & edge cases
Section titled “5. Threat model, failure modes & edge cases”- Sharp source in the DOM. Blur applies CSS filters to a normal
FlagImage; Spotlight draws a canvas over a normal<img>. Either is one DevTools toggle from the answer. The flag URL is opaque (see Opaque flag tokens),hideCountryInAltkeeps the Blur alt text at “Flag”, and Spotlight setsalt="Flag"until the round ends. The target is picked client-side, so the answer is also in memory. - No server check. These are side challenges with no XP. Completion goes to a local record (
recordChallengeCompletionLocally), a product analytics event, and a fire-and-forget POST. Cheating changes nothing shared. - Rapid Blur guesses. A second guess inside 500 ms clears the timer and keys a new outgoing layer from the current snapshot, so at most two filter layers exist.
- Reduced motion.
prefersReducedMotion()reads thereduce-motionclass on<html>(driven by the app’s motion setting), not the media query directly. With it on, Blur snaps, the fog intro is skipped, and the reveal flash is hidden. - Spotlight image load. Until the
<img>is decodable, the canvas haspointer-events-noneand the handler returns early. SVG flags count as loaded even whennaturalWidthis 0, becauseflagImageReadychecksisSvgFlagUrl. - Silent rejections. A too-close tap gives no feedback at all. The hover preview (mouse and pen only; touch skips
pointermove) shows a dashed circle, but touch players get nothing until a tap lands. - Flag rect lookup.
getLiveFlagRectqueriesimg[alt^='Flag of'], which only matches after the round ends. During play it falls back to the boundimageRef, which is the same element, so the result is unchanged. - Snapshots in results. Each guess stores
captureSnapshot(), a 600×400 WebP data URL at quality 0.5 composed from the image and the mask. The<img>hascrossorigin="anonymous"so the offscreen canvas stays untainted. If the CDN stopped sending CORS headers, the image would fail to load; theerrorlistener still marks it loaded, so the round would play over an empty frame.
6. Architectural tradeoffs & non-goals
Section titled “6. Architectural tradeoffs & non-goals”| Choice | Alternative | Why the code does this |
|---|---|---|
| Crossfade static filter snapshots | transition: filter | Opacity animates without re-running the blur each frame. The cost is a blend of two blur levels during the fade instead of a true continuous deblur. |
| Linear schedule, 5 px and 17% per guess | Perceptual curve (for example, equal steps in recognizability) | Simple, predictable, easy to share as “revealed 60%”. The first steps from 26 px barely help on flags with large color fields. |
Hard-edged destination-out circles | Radial gradient or feathered mask | Players can see exactly what they paid for, and the stroke outline marks each hole. |
Canvas mask over an <img> | Draw the flag into the canvas | Keeps the opaque CDN image path and browser decoding; the canvas holds only the mask. The flag stays in the DOM, which is the main cheat vector. |
| Silent drop within | Shake, or spend the move anyway | Avoids punishing double taps on touch screens. Players get no signal that the tap was ignored. |
| Moves separate from guesses | One shared budget | Lets Hard be a 3-tap puzzle while keeping 5 guesses across all difficulties. |
Non-goals: hiding the flag from someone with DevTools open, verifying results server-side, and awarding XP for these modes. The opaque URL and click protection only defeat casual inspection.