Skip to content

Flag Builder (/challenges/flag-builder) names a country and asks the player to rebuild its flag in three steps: pick a layout, paint each region, then choose a symbol and where it goes. The result screen compares the answer with the stored puzzle and scores it out of MAX_SCORE (10).

A flag in this game is a small record, not an image:

apps/web/src/routes/challenges/flag-builder/flag-builder-logic.ts
export type FlagSymbol =
| "none"
| "star"
| "stars"
| "sun"
| "crescent-star"
| "cross"
| "shield"
| "leaf"
| "bird"
| "circle"
| "emblem";
export type SymbolPlacement = "center" | "canton" | "hoist" | "none";

Each puzzle in FLAG_BUILDER_PUZZLES stores a countryCode, a layout, an ordered colors array (one entry per region), a symbol, an optional symbolVariant, and a symbolPlacement. The same record type drives the live preview and the answer key, so the preview is exactly what gets scored.

canVisitPhase enforces the order: colors needs a layout, and symbol needs a layout plus colorsComplete (every region has a color). Going back is always allowed.

Flag Builder is live and client-side. getRandomPuzzle picks uniformly from 59 hand-written puzzles with Math.random. For every puzzle, colors.length equals LAYOUT_REGION_COUNT[layout].

Not every layout or symbol is used as an answer. Three layouts (horizontal-top-heavy, saltire, quarters) appear in the picker but no puzzle uses them, so they only ever cost the player points. The bird symbol is drawn by the renderer but no puzzle uses it either.

buildLayoutSvg(layout, colors, W, H) returns SVG markup for the background. Region order matches the labels from getRegionLabels (for example Field, Canton). Geometry below is for any W × H. The canvas uses 300 × 200.

LayoutRegionsGeometryPuzzles
solid1One full rect6
horizontal-bicolor2Two bands of H/2H/23
horizontal-tricolor3Three bands of H/3H/315
horizontal-tricolor-wide-center3Bands 0.2H0.2H, 0.6H0.6H, 0.2H0.2H4
horizontal-top-heavy3Bands 0.5H0.5H, 0.25H0.25H, 0.25H0.25H0
vertical-bicolor2Two bands of W/2W/22
vertical-tricolor3Three bands of W/3W/39
nordic-cross2Arms 0.15W0.15W thick, vertical arm centred at x=0.3Wx = 0.3W4
symmetric-cross2Arms 0.15W0.15W thick, centred1
diagonal2Triangle (0,0),(W,0),(0,H)(0,0), (W,0), (0,H) over the second color2
canton2Canton rect 0.4W×0.5H0.4W \times 0.5H at the top left7
saltire2Two corner-to-corner lines, stroke 0.2H0.2H0
circle-on-solid2Circle at (0.45W,H/2)(0.45W, H/2), radius 0.28H0.28H2
triangle-hoist2Bands color 1, white, color 1, then triangle to (0.42W,H/2)(0.42W, H/2)2
chevron3Two bands of H/2H/2, triangle to (0.45W,H/2)(0.45W, H/2)2
quarters2Four W/2×H/2W/2 \times H/2 quadrants, colors on the diagonals0

Two representative cases, verbatim:

apps/web/src/routes/challenges/flag-builder/flag-builder-render.ts
case "nordic-cross": {
const armW = W * 0.15;
const xOff = W * 0.3;
return `<rect x="0" y="0" width="${W}" height="${H}" fill="${c(0)}"/><rect x="${xOff - armW / 2}" y="0" width="${armW}" height="${H}" fill="${c(1)}"/><rect x="0" y="${H / 2 - armW / 2}" width="${W}" height="${armW}" fill="${c(1)}"/>`;
}
apps/web/src/routes/challenges/flag-builder/flag-builder-render.ts
case "circle-on-solid":
return `<rect x="0" y="0" width="${W}" height="${H}" fill="${c(0)}"/><circle cx="${W * 0.45}" cy="${H / 2}" r="${H * 0.28}" fill="${c(1)}"/>`;

An unset region renders in one of FLAG_REGION_PLACEHOLDERS (#3F4D6B, #8B9BB8, #D7DEEA), chosen by region index, so empty regions stay distinguishable in the preview.

buildSymbolSvg places every symbol at an anchor and sizes it from one radius:

apps/web/src/routes/challenges/flag-builder/flag-builder-render.ts
function symbolAnchor(
placement: string | null,
viewWidth: number,
viewHeight: number
): SymbolAnchor {
if (placement === "canton") {
return { x: viewWidth * 0.18, y: viewHeight * 0.28 };
}
if (placement === "hoist") {
return { x: viewWidth * 0.2, y: viewHeight * 0.5 };
}
return { x: viewWidth / 2, y: viewHeight / 2 };
}

The radius is Math.min(viewWidth, viewHeight) * 0.16, which is 32 on the 300×200 canvas. pickSymbolPaint reads the region behind the symbol (region 1 for a canton placement, else region 0). A background with relative luminance above 0.62 gets a near-black symbol. A red-family background (red, dark-red, crimson, maroon) gets gold #FFD54F. Anything else gets off-white #F7F3E8.

buildFlagSvg joins layout, symbol and a 1.5-unit border rect. It skips the symbol when no layout is chosen.

apps/web/src/routes/challenges/flag-builder/flag-builder-logic.ts
const layoutPoints = answer.layout === puzzle.layout ? 3 : 0;
let colorPoints = 0;
if (layoutPoints > 0 && answer.colors.length === puzzle.colors.length) {
const correct = answer.colors.filter(
(color, index) => color === puzzle.colors[index]
).length;
// Scale to 0–4 points
colorPoints = Math.round((correct / puzzle.colors.length) * 4);
}
let symbolPoints = 0;
if (answer.symbol === puzzle.symbol) {
symbolPoints = 1;
const variantMatches =
puzzle.symbolVariant == null || answer.symbolVariant === puzzle.symbolVariant;
if (variantMatches) {
symbolPoints += 1;
}
if (answer.symbolPlacement === puzzle.symbolPlacement) {
symbolPoints += 1;
}

The code continues with an else if that awards 3 when both symbols are none. That branch cannot run: two none values are equal, so the first branch already handles them (variant is null, and the UI sets placement to none, giving 1 + 1 + 1 = 3).

handleSymbolConfirm then reports didWin: finalScore.total >= 8 and a guessCount of one for the layout, one per painted region, and one for the symbol. getScoreGrade labels 10 Perfect, 8–9 Excellent, 6–7 Good, 4–5 Fair, and below 4 Try Again.

With nn regions and kk correct, color points are

C(k,n)=round⁡ ⁣(4kn).C(k, n) = \operatorname{round}\!\left(\frac{4k}{n}\right).
nnk=0k = 0k=1k = 1k=2k = 2k=3k = 3
104
2024
301 (1.33)3 (2.67)4

On a three-region flag the second correct color is worth 2 points and the first and third are worth 1 each.

Let L∈{0,3}L \in \{0, 3\}, C∈[0,4]C \in [0, 4] and S∈[0,3]S \in [0, 3]. Since C=0C = 0 whenever L=0L = 0,

L=0  ⟹  total=S≤3<8,L = 0 \implies \text{total} = S \le 3 < 8,

so the layout is mandatory for a win. With the layout right, a win needs C+S≥5C + S \ge 5. That allows, for example, all colors plus the right symbol in the wrong place (3+4+1=83 + 4 + 1 = 8), or two of three colors plus symbol and placement (3+3+2=83 + 3 + 2 = 8 when the puzzle has no variant).

For a puzzle with nn regions, the player chooses from 16 layouts, 25n25^n color assignments, 11 symbols and up to 3 placements (plus variants where they exist). For a three-band flag and a plain symbol that is

16×253×11×3=8,250,00016 \times 25^{3} \times 11 \times 3 = 8{,}250{,}000

combinations, but the phases split the problem: the layout is judged on its own, then each region is one choice out of 25.

On the 300×200 canvas the canton rect spans [0,120]×[0,100][0, 120] \times [0, 100] and the canton anchor is (54,56)(54, 56), slightly left of and below the rect centre (60,50)(60, 50). The symbol radius of 32 fits inside the canton with at least 22 units to spare on the left edge. The hoist anchor (60,100)(60, 100) sits inside the triangle-hoist triangle, whose tip reaches x=126x = 126.

5. Threat model, failure modes & edge cases

Section titled “5. Threat model, failure modes & edge cases”
  • Answer exposure. The puzzle record is in the client bundle and component state. Flag Builder awards nothing that is verified server-side.
  • Unused layouts. horizontal-top-heavy, saltire and quarters are never correct. A player who picks one always scores at most 3.
  • Hard-coded white. triangle-hoist always draws color 1, white, color 1, then the triangle in color 2. Cuba (blue, red) comes out as a close approximation. The Philippines puzzle also stores blue, red, so its answer renders as blue-white-blue bands with a red triangle, while the real flag is blue over red with a white triangle. A player who paints the stored blue, red scores full color points while the preview looks unlike the real flag.
  • Layout change after painting. ensureColorSlots resets colors only when the region count changes. Switching between two layouts with the same count keeps the painted colors in region order, which may put them in a different place on the flag.
  • Placement default. Entering the symbol phase sets placement to center if none was chosen. A player who picks the right symbol but never touches placement gets the placement point only when the puzzle’s placement is center.
  • Proportions. Every flag is drawn 3:2, so shape is not part of the challenge. Square flags and 1:2 flags look the same as 3:2 flags here.
DecisionBenefitCost
Parametric layouts, not traced flagsSmall data, instant preview, exact scoring59 of 205 countries covered. Many real flags cannot be expressed.
Fixed 3:2 canvasOne geometry per layout, simple testsProportion is lost.
Colors gated on layoutPrevents partial credit on a wrong structureHarsh: a near-miss layout scores at most 3.
Symbol paint from luminanceSymbols stay visible on any backgroundSymbol color is picked for contrast, not taken from the real flag, and is not scored.
Uniform random puzzleNo backend, no historyRepeats are possible. 15 of 59 puzzles are horizontal tricolors.

Non-goals: pixel-accurate flags (the rest of the site uses FlagImage for that), free drawing, and scoring symbol color or size.