Skip to content

The Puzzle challenge (/challenges/puzzle) cuts a flag into a grid, scrambles the tiles, and asks the player to rebuild it. A move picks any two tiles and swaps them: tap one then another, or drag one onto another. Tiles never slide, and there is no blank cell.

The board is an array tilesBySlot, where tilesBySlot[slot] is the index of the piece shown in that slot. Piece ii belongs in slot ii. Mathematically the board is a permutation π\pi of {0,…,n−1}\{0, \ldots, n-1\}, a swap is a transposition, and the result screen compares the player’s swap count with the fewest transpositions that would have solved the starting board.

Many flags have tiles that look the same: the top and bottom tile of each stripe in a vertical tricolor, most of Japan’s white field. The engine treats those tiles as interchangeable. It fingerprints each tile’s pixels, calls the puzzle solved when every slot shows a tile that looks right, and computes par against the best of all visually solved layouts.

The shared shuffle is a textbook Fisher–Yates on a copy of the array:

packages/shared/src/game-logic/game-logic.ts
export function shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}

The puzzle wraps it in a rejection loop so the identity never ships:

apps/web/src/routes/challenges/puzzle/puzzle-utils/board.ts
export const createShuffledBoard = (tileCount: number) => {
const solvedBoard = Array.from({ length: tileCount }, (_, index) => index);
let shuffledBoard = shuffleArray(solvedBoard);
while (isStrictlySolved(shuffledBoard)) {
shuffledBoard = shuffleArray(solvedBoard);
}
return shuffledBoard;
};

createInitialPuzzleBoard adds a second loop for visual solves: up to MAX_VISUAL_RESHUFFLE_ATTEMPTS = 24 reshuffles, then breakSolvedLayout, which tries every pair swap until one breaks the visual solve. For a flag whose tiles all look the same, usesIdentityPuzzleSolve switches the solve check back to strict identity, so the puzzle stays winnable in a meaningful way.

A swap is a two-element exchange on a copy of the array, followed by the solve check:

apps/web/src/routes/challenges/puzzle/PuzzleChallenge.svelte
const nextTilesBySlot = [...tilesBySlot];
[nextTilesBySlot[fromSlot], nextTilesBySlot[toSlot]] = [
nextTilesBySlot[toSlot]!,
nextTilesBySlot[fromSlot]!,
];
const solved = isPuzzleSolved(nextTilesBySlot, pieceSignatures);
const nextSwapCount = swapCount + 1;

Every swap counts, including swaps of two identical-looking tiles.

apps/web/src/routes/challenges/puzzle/puzzle-utils/board.ts
export const countCyclesOfPermutation = (mapping: number[]) => {
const n = mapping.length;
const seen = new Array<boolean>(n).fill(false);
let cycles = 0;
for (let i = 0; i < n; i += 1) {
if (seen[i]) {
continue;
}
cycles += 1;
let j = i;
while (!seen[j]) {
seen[j] = true;
j = mapping[j] ?? j;
}
}
return cycles;
};

minSwapsToIdentity returns n − countCyclesOfPermutation(tilesBySlot). Fixed points count as cycles of length 1.

getPieceSignaturesForCountry loads the SVG flag, draws it on a canvas columns × 48 px wide at the flag’s true aspect ratio, and hashes the interior of each tile (a 4 px inset keeps anti-aliased seams out):

apps/web/src/routes/challenges/puzzle/puzzle-utils/signatures.ts
const quantizeChannel = (channel: number) =>
Math.min(255, Math.round(channel / COLOR_QUANTUM) * COLOR_QUANTUM);
export const hashPixelData = (pixelData: Uint8ClampedArray, stride = 8) => {
let hash = 2166136261;
for (let index = 0; index < pixelData.length; index += stride) {
hash ^= quantizeChannel(pixelData[index] ?? 0);
hash = Math.imul(hash, 16777619);
hash ^= quantizeChannel(pixelData[index + 1] ?? 0);
hash = Math.imul(hash, 16777619);
hash ^= quantizeChannel(pixelData[index + 2] ?? 0);
hash = Math.imul(hash, 16777619);
}
return `${hash >>> 0}`;
};

That is FNV-1a 32-bit over quantized RGB (COLOR_QUANTUM = 32), reading every second pixel (stride 8 bytes) and ignoring alpha. Results are cached per code:columns×rows. If the image fails to decode or the canvas is tainted, the function returns null and the game falls back to strict identity.

minSwapsToVisualSolve picks a strategy by the number of distinct signatures KK:

The K≤3K \le 3 path covers most striped flags and avoids the 5!3≈1.75!^3 \approx 1.7 million layouts a 5×3 tricolor would otherwise enumerate:

apps/web/src/routes/challenges/puzzle/puzzle-utils/signatures.ts
const { mismatch, misplaced } = buildMismatchMatrix(
tilesBySlot,
pieceSignatures,
colorIndex
);
let cycles = extractTwoCyclesInPlace(mismatch);
if (k === 3) {
cycles += countResidualThreeCycles(mismatch);
}
return misplaced - cycles;

Each tile is a <button> whose background is the whole SVG, scaled up and shifted:

apps/web/src/routes/challenges/puzzle/puzzle-utils/board.ts
export const getBackgroundPercent = (index: number, total: number) => {
if (total <= 1) {
return "0%";
}
return `${(index / (total - 1)) * 100}%`;
};

with background-size: columns·100% rows·100%. The flag is loaded once and the grid needs no image slicing.

At step ii (counting down from n−1n-1), the element placed at index ii is chosen uniformly from the i+1i+1 still unplaced. Each permutation arises from exactly one sequence of choices, so

P(π)=∏i=1n−11i+1=1n!P(\pi) = \prod_{i=1}^{n-1} \frac{1}{i+1} = \frac{1}{n!}

Rejecting the identity makes the start uniform over the n!−1n! - 1 other permutations (719 for Easy, about 4.8×1084.8 \times 10^8 for Medium, 1.3×10121.3 \times 10^{12} for Hard), before the visual-solve rejection trims a few more.

Minimum transpositions =n−c(π)= n - c(\pi)

Section titled “Minimum transpositions =n−c(π)= n - c(\pi)=n−c(π)”

Let c(π)c(\pi) be the number of cycles of π\pi, fixed points included. The identity is the only permutation with c=nc = n.

Lemma. For any permutation π\pi and transposition τ=(a  b)\tau = (a\;b), c(π∘τ)=c(π)±1c(\pi \circ \tau) = c(\pi) \pm 1. If aa and bb lie in the same cycle, the swap splits it in two; if they lie in different cycles, it merges them.

Lower bound. Each swap raises cc by at most 1. Going from c(π)c(\pi) to nn takes at least n−c(π)n - c(\pi) swaps.

Achievability. In a cycle of length k>1k > 1, swap the slot holding some piece with that piece’s home slot. The piece becomes a fixed point and the cycle shrinks to length k−1k - 1, so cc goes up by exactly 1. A kk-cycle takes k−1k - 1 swaps, and the total is

∑cycles(kj−1)=n−c(π)\sum_{\text{cycles}} (k_j - 1) = n - c(\pi)

which meets the lower bound. For the board in the diagram, 6−3=36 - 3 = 3.

Interactive

Puzzle swap bench

Each tile is a distinct piece, so par is n minus the number of cycles. The production puzzle also treats lookalike tiles as interchangeable; this bench is the distinguishable case from minSwapsToIdentity().

Cycles
3
Min swaps
6
Your swaps
0

Tap a tile, then another, to swap them.

A swap is a transposition, and transpositions generate the full symmetric group SnS_n. Every shuffled board is solvable, so the code has no solvability check.

Sliding puzzles are different. There, every move swaps the blank with a neighbor, and returning the blank to its home cell takes an even number of moves. Only permutations whose parity matches that constraint are reachable, and half of the scrambles have no solution. The Puzzle challenge has no blank, so the constraint never arises.

Parity still shows up in the score. The sign of a permutation is sgn⁡(π)=(−1)n−c(π)\operatorname{sgn}(\pi) = (-1)^{n - c(\pi)}, and any sequence of mm swaps that reaches the identity satisfies m≡n−c(π)(mod2)m \equiv n - c(\pi) \pmod 2. On a flag where every tile looks different, swapsOverMin is therefore always even: a player who wastes one swap has to waste a second to undo it. With look-alike tiles there are several target layouts of different parity, and odd overshoots are possible.

For a uniformly random permutation, E[c]=Hn=∑k=1n1/k\mathbb{E}[c] = H_n = \sum_{k=1}^{n} 1/k. Conditioning on “not the identity” changes this in the third decimal place for n=6n = 6 and less for larger nn.

GridnnHnH_nExpected par n−Hnn - H_nWorst case n−1n - 1
Easy 3×262.4503.555
Medium 4×3123.1038.9011
Hard 5×3153.31811.6814

These hold for flags with all-distinct tiles. Look-alike tiles lower the par.

Group the piece indices by signature: G1,…,GKG_1, \ldots, G_K. A layout is visually solved when every slot ss holds a piece with the same signature as piece ss. The targets are the permutations σ\sigma that map each group onto itself, and reaching σ\sigma from π\pi costs n−c(σ−1π)n - c(\sigma^{-1} \pi) swaps. So

par(π)=n−max⁡σ∈G1!×⋯×GK!c(σ−1π)\text{par}(\pi) = n - \max_{\sigma \in G_1! \times \cdots \times G_K!} c(\sigma^{-1}\pi)

The general branch enumerates every σ\sigma with a depth-first walk over orderings of each group, which is ∏j∣Gj∣!\prod_j |G_j|! candidates, capped at 750,000.

For K≤3K \le 3 the code uses a closed form. Build a directed multigraph on the KK colors with one edge A→BA \to B for each slot of color AA that holds a tile of color B≠AB \ne A. Let mm be the number of such misplaced slots. Every color has equal in-degree and out-degree (as many AA tiles sit outside AA slots as AA slots hold foreign tiles), so the edges split into cycles, and

par=m−(maximum number of edge-disjoint cycles)\text{par} = m - (\text{maximum number of edge-disjoint cycles})
  • K=2K = 2: mAB=mBAm_{AB} = m_{BA}, all cycles are 2-cycles, and par =m/2= m/2.
  • K=3K = 3: greedily removing 2-cycles is optimal. What remains has no opposite edge pairs, and balance forces it to be tt copies of one directed triangle. Par =m−∑i<jmin⁡(mij,mji)−t= m - \sum_{i<j}\min(m_{ij}, m_{ji}) - t.

With background-size k⋅100%k \cdot 100\% along an axis and background-position pp, CSS shifts the image by p⋅(box−image)=p(1−k)⋅boxp \cdot (\text{box} - \text{image}) = p(1 - k)\cdot\text{box}. Tile ii needs a shift of −i⋅box-i \cdot \text{box}, so p=i/(k−1)p = i / (k - 1), which is getBackgroundPercent.

5. Threat model, failure modes & edge cases

Section titled “5. Threat model, failure modes & edge cases”
  • Par above the true minimum. The 750,000 cap can send a flag with 4 or more tile classes and one large group (for example group sizes 8, 3, 2, 2 on a 5×3 grid: 8!⋅3!⋅2!⋅2!≈9.7×1058! \cdot 3! \cdot 2! \cdot 2! \approx 9.7 \times 10^5) to the identity fallback. Par is then too high, and max(0, …) turns a below-par finish into “Perfect solve”.
  • Signature merges. Tiles that differ only inside the 4 px inset border, or by less than one 32-level quantization step, hash the same and count as interchangeable. The board can be accepted as solved while a thin detail sits in the wrong place.
  • Hash collisions. Two genuinely different tiles colliding in 32 bits within one board of at most 15 tiles has probability around 152/(2⋅232)≈2.6×10−815^2 / (2 \cdot 2^{32}) \approx 2.6 \times 10^{-8}.
  • CORS or decode failure. loadFlagImage sets crossOrigin = "anonymous" for cross-origin URLs. If decoding fails or getImageData throws, signatures are null: the solve check becomes strict identity and par uses the distinct-tile formula. The puzzle stays playable but may demand swapping identical-looking tiles.
  • Race on difficulty change. startNewGame increments gameStartRequest and drops the result of any older async signature load, so switching grids quickly cannot install a stale board.
  • No integrity check. The board, the par, and the swap count all live in the client. A completion posts guessCount: finalSwapCount best-effort; nothing verifies it.
ChoiceAlternativeWhy the code does this
Swap any two tilesSliding tiles with a blankNo parity trap, no dead scrambles, and the score has a clean closed-form par. Easier on touch screens.
Visual solve via pixel signaturesRequire every numbered piece in its home slotPlayers cannot tell identical tiles apart; demanding a specific one would feel broken.
Closed form for K≤3K \le 3, enumeration otherwiseAlways enumerate, or always use the identity countEnumeration blows up on 5×3 tricolors; the identity count overstates par on striped flags.
Fallback to an upper bound past 750,000Exact search (for example, max cycle packing)Bounded latency on a UI thread. The cost is a rare generous “Perfect solve”.
One SVG as a scaled CSS backgroundPre-sliced tile imagesOne request, sharp at any size, and the opaque flag URL is reused.

Non-goals: timed scoring, move undo, preventing a player from reading the answer (the country name is in client state from the start), and server-verified results.