diff --git a/CLAUDE.md b/CLAUDE.md
index feea2a2..a561c24 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -31,6 +31,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
### Save Queue
`game-store.ts` serialises all DB writes through a module-level `_saveQueue` promise chain so concurrent calls never race on `gameId`. Any code that must read the final saved `gameId` (e.g. `endGame()` needs `gameId` to look up rank) must `await` the action that triggers the save before reading state.
+### Board generation & order strategies
+Boards are **solvable by construction** (`src/utils/init-gameboard.ts`): the fixed layout is peeled into a removal order of free pairs, then matched token pairs are laid onto it, so replaying the order always wins. Freeness is purely geometric and lives in one shared rule (`src/utils/board-rules.ts` `isSelectable`), reused by both the live game (`allowedforSelection`) and the generator.
+
+The peel order policy is a **ports & adapters seam** (`src/utils/order-strategies.ts`). The port is `OrderStrategy.peel(random)`; selectable adapters only express *which two free tiles to take next* via `peelWith`: `scatter` (**production default** — pairs the highest free tile with the lowest, spreading matches across layers), `topDownRandom`, `original`, `bottomUpRandom`. Note the layer-greedy strategies (`topDownRandom`/`bottomUpRandom`) always pair the top-of-pyramid tiles among themselves — a visible "ready-made" cluster of matching pairs at the top — which is why `scatter` is the default. Solvability holds for every adapter because the shared loop only ever removes currently-free tiles; a policy that strands just returns `null` and `dealSolvableBoard` falls back to `canonical` — a deterministic top-down order that never strands and is kept out of the selectable list (internal safety net only).
+
+To experiment locally, override the active strategy (resolution: runtime override → localStorage `eureka.orderStrategy` → `NEXT_PUBLIC_ORDER_STRATEGY` env → default). In dev, the `/play` screen shows a strategy badge (`src/components/strategy-switcher`): press `s` / `Shift+S` (or click) to cycle and re-deal; hover for descriptions. From the browser console (any build): `eurekaOrder.set('original')` then start a new game; `eurekaOrder.list()` to see options; `eurekaOrder.set(null)` to reset. Production play is unaffected unless one of those overrides is set.
+
### Tile animations
Tile animation state lives in `gameBoard[index].animating` (`'match' | 'mismatch' | null`). The `clicked()` action sets the flag and uses `setTimeout` to clear it after the CSS animation completes. CSS classes `tile.match` and `tile.mismatch` are defined in `global.css`.
diff --git a/docs/gifs/construction.gif b/docs/gifs/construction.gif
new file mode 100644
index 0000000..9b051fa
Binary files /dev/null and b/docs/gifs/construction.gif differ
diff --git a/docs/gifs/freeness.gif b/docs/gifs/freeness.gif
new file mode 100644
index 0000000..fc45dea
Binary files /dev/null and b/docs/gifs/freeness.gif differ
diff --git a/docs/gifs/random.gif b/docs/gifs/random.gif
new file mode 100644
index 0000000..9e671ff
Binary files /dev/null and b/docs/gifs/random.gif differ
diff --git a/docs/solvable-boards.html b/docs/solvable-boards.html
new file mode 100644
index 0000000..098a5eb
--- /dev/null
+++ b/docs/solvable-boards.html
@@ -0,0 +1,363 @@
+
+
+
A mahjong board can look perfect and still have no solution. Here's how we stopped leaving that to chance — in three short clips.
+
+
The board 144 tiles · 5 layers · 36 symbols
+
The bug random fill ⇒ deadlocks
+
The fix record a solution first
+
+
↓ scroll
+
+
+
+
+
+
+ the rule
+
+ loops
+
+
+
first, one rule
+
You can only take a free tile.
+
A tile is free when nothing covers it and a side is open. Match two free tiles of the same symbol and they leave the table.
+
The crucial part: freeness depends only on where tiles sit — never on their symbols. And taking tiles away only ever frees more. Hold onto those two facts.
+
+
+
+
+
+
+
+
+
+
approach one
+
Deal & pray.
+
The obvious method: scatter the symbols onto the layout at random and hope. Played as well as possible, a random board still strands tiles that can never be matched.
+
+
+
+ live play → deadlock
+
+ loops
+
+
+
no symbols left to match
+
The board was lost when it was dealt.
+
Every move shown is forced and optimal, yet the last tiles have their partners buried or hemmed in on opposite ends. An exhaustive search confirms no sequence wins it.
+
+
+
+
how often is it impossible?
+
0%
+
+
0%50%100%
+
Dealing random boards in your browser and checking each for any solution…
+
+
+ Computed live, on the same 16-tile board as the clips (small enough to follow every move). On the full 144-tile table the odds of a dead deal are far worse. The verdict holds: with random fill, winning is partly luck of the draw — which quietly erases the player's skill.
+
+
+
+
+
+
+
+
+
approach two
+
Record a solution, then deal it.
+
Flip the problem around. Instead of dealing tiles and searching for a solution, build the solution first and lay the tiles onto it.
+
+
+
+ peel → paint → replay
+
+ loops
+
+
+
solvable by construction
+
Three acts, one guarantee.
+
+
Peel the empty layout — lift any two free slots and note the pair, until the table is clear. This always finishes, and the order is a guaranteed winning sequence.
+
Paint a matching pair of symbols onto each recorded step.
+
Replay the notes — every step is free and matches, so the board always clears.
+
+
+
+
+ The symbols are still shuffled, so boards stay varied — only the possibility of an impossible deal is gone.
+
+
+
+
+
+
+
+
+
the difference
+
From a coin-flip to a craft.
+
+
+
+
deal & pray
+
Solvability is random.
+
Some boards can't be won no matter how you play. A loss might be the deal's fault, not yours — and nothing can tell the difference.
+
+
+
record & deal
+
Solvability is guaranteed.
+
Every board has a winning line by construction. If you lose, it's a move you made. Now the timer and the leaderboard measure skill — the whole point.
+
+
+
+
+
+
+
+
+
+
why it's airtight
+
Two properties do all the work.
+
The whole guarantee rests on facts about freeness — the same rule the live game uses to decide what you can click.
+
+
+
+ ① Freeness is geometric.
+
What can be taken depends only on layer / row / column and which tiles remain — never on the symbol. So any symbol assignment over a valid removal order is winnable.
+
+
+ ② Removal is monotone.
+
Taking a tile away can only free more tiles, never cover one back up. So peeling the layout pair-by-pair always reaches the empty table — the recorded order can't get stuck.
+
+
+
The generator, in essence:
+
// 1. Peel the fixed layout into a valid removal order (geometry only).
+const pairs = peelLayout(random); // 72 pairs, each free when removed
+
+// 2. Lay matched token pairs onto that order.
+const pairTokens = shuffle([...tokens, ...tokens]); // each symbol ×2 pairs
+pairs.forEach(([a, b], i) => {
+ board[a] = board[b] = { token: pairTokens[i] }; // the pair matches
+});
+
+// Replaying `pairs` wins ⇒ solvable by construction, for every shuffle.
+// Verified by re-solving 360 random deals in tests.
Every board is now guaranteed solvable. This brief asks the next question: how hard should it be, and which lever do we pull to set that.
+
measured on the real 144-tile board · proxies, not playtests · figures reproducible from src/utils/order-strategies.ts
+
+
+
+
+
the short version
+
Recommendation
+
Keep scatter as the default, and add a difficulty dial via the symbol-labeling knob we haven't touched yet.
+
We shipped scatter because it removed a visible artifact (matching pairs pre-stacked at the top of the pyramid — 100% of old-default boards). The trade-off, now measured: scatter is ~28% more forgiving than the previous default (9.6 vs 7.5 legal moves per step). That matches playtest feedback ("plays well, but a bit easier").
+
Crucially, difficulty today is an accidental side-effect of board shape. The deliberate lever — how a symbol's copies are spread out — is still random and identical across every strategy. That's the dial to add.
+
+
+
+
+
+
+
background
+
One artifact we already fixed
+
Boards are built by recording a solution: peel the layout into a removal order, then lay matched symbol-pairs onto it. The peel policy used to cluster the top of the pyramid into ready-made matching pairs — "two matching pairs sitting right below the top tile," every game.
+
+
strategy
top-4 tiles self-paired
same-layer pairs
+
+
topDownRandomold default
100%
96%
+
bottomUpRandom
100%
46%
+
original
0%
47%
+
scatterdefault
0%
21%
+
+
+
scatter pairs the highest free tile with the lowest, spreading matches across layers — so no layer becomes a self-contained giveaway. All strategies remain 100% solvable; scatter never falls back.
+
+
+
+
+
+
the model
+
Two independent knobs
+
Geometry is fixed and symbol frequencies are fixed, so a board is fully determined by two choices — and they do different jobs.
+
+
+
① Peel order
+
Which positions get paired. Controls the board's look and, as a side-effect, how many moves are open at once (branching). This is the knob we've been turning (topDown / bottomUp / scatter / original).
+ tuned
+
+
+
② Symbol labeling
+
Which pairs get the same symbol. Controls how far apart a symbol's copies are in the solve — the deliberate difficulty lever. Currently pure-random and identical across all four strategies.
+ untouched ← the opportunity
+
+
+
+
+
+
+
+
measurement
+
How hard is each strategy?
+
We walked each board's guaranteed solution and, at every step, counted the legal moves available (free tiles that share a symbol). More moves per step = more forgiving. We also tracked how often a step is forced (exactly one move).
+
+
+
Branching — mean legal moves available per step (higher = easier)
+
bottomUpRandom
4.5
+
topDownRandom
7.5
+
scatter ◂ default
9.6
+
original
9.7
+
+
+
+
Forced-move rate — % of steps with only one legal move (higher = harder / tenser)
+
bottomUpRandom
30.0%
+
topDownRandom
3.8%
+
scatter ◂ default
3.4%
+
original
3.8%
+
+
+
+
strategy
branching
forced %
opening moves
copy-spread
+
+
bottomUpRandom
4.5
30.0%
11.9
0.34
+
topDownRandomold
7.5
3.8%
16.6
0.35
+
scatterdefault
9.6
3.4%
15.1
0.33
+
original
9.7
3.8%
18.4
0.34
+
+
+
+
+
+
+
+
learnings
+
What the numbers say
+
+
Difficulty ranks by branching: bottomUp (4.5, hardest) → topDown (7.5) → scatter (9.6) ≈ original (9.7, easiest). The playtest read of "a bit easier" is real and ~28% in size.
+
"Hard" is not free today. The only meaningfully harder option, bottomUp, is hard because it forces single moves 30% of the time — but it carries the 100% top-cluster artifact. We have no "hard and good-looking" strategy yet.
+
The difficulty lever is unused. Copy-spread sits at ~0.34 for every strategy because symbol labeling is random in all of them. Difficulty has only ever moved as a byproduct of board shape — never on purpose.
+
Implication for highscores: an easier default likely nudges scores up over time. If we want to hold a target, difficulty needs to become a controlled input, not an accident.
+
+
+
+
+
+
+
proposal
+
Where to go next
+
+
+
recommended
+
Difficulty dial
+
Keep scatter's look; turn the labeling knob. A 0→1 dial spreads a symbol's copies from clustered (easy) to far-apart in the solve (hard). Can scale with level to offset grace tiles, which currently make late levels easier.
+
effort: small · reversible · no look change
+
+
+
for consistency
+
Metric-driven deals
+
Generate ~20 candidate boards per deal (≈ms each), keep the one closest to a target difficulty score. Removes board-to-board variance — the cleanest way to stabilize highscores.
+
effort: medium · more compute/deal
+
+
+
optional
+
More peel flavors
+
balanced / ring / outward, for board character. Lower difficulty impact than labeling — mostly aesthetic variety.
+
effort: small · mostly look
+
+
+
+
+
+
+
+
decisions for the team
+
Open questions
+
Is "a bit easier" the goal?
More approachable for new players, or do we want to restore the old default's challenge — or sit somewhere between?
+
What's the target difficulty band?
Pick a branching range to aim for (e.g. hold ~6–8 moves/step), so the dial has a destination.
+
Should difficulty rise with level?
Today grace tiles make higher levels easier. A level-scaled dial would reverse that curve. Do we want a rising challenge?
+
Stabilize per-board difficulty, or keep variance?
Metric-driven deals make every board feel comparable; some randomness in difficulty can also feel more organic. Which do we want?
+
+
+
+
+
+
methodology
+
+
Figures are computed, not playtested — relative indicators of difficulty, not absolute. For each strategy we deal boards on the real 144-tile layout, then walk each board's guaranteed solution under the live selection rule (isSelectable), measuring at every step:
+
+
branching — count of legal matchable free-tile pairs available;
+
forced-move rate — share of steps with exactly one such pair;
+
copy-spread — normalized distance between a symbol's two pairs in the removal order.
+
+
Artifact figures over 300 deals; difficulty figures over 20 deals each. Caveats: this walks one valid solution rather than modelling real player choices or running a full search, so treat the numbers as a consistent yardstick for comparing strategies, not as a calibrated difficulty score.
+ s next · ⇧s prev · r re-deal (same strategy) · or click
+
+ note: strategies change the hidden solution, not the visible layout.
+
+
+
+
+
+ );
+};
+
+export default StrategySwitcher;
diff --git a/src/db/select-game.ts b/src/db/select-game.ts
index 9cd1167..a0d22fc 100644
--- a/src/db/select-game.ts
+++ b/src/db/select-game.ts
@@ -5,6 +5,7 @@ interface RankedHighscore {
name: string;
score: number;
level: number;
+ strategy: string;
created_at: string;
rank: number;
}
@@ -14,7 +15,7 @@ const LIMIT = 10;
export const getHighscores = async (): Promise => {
const { data, error } = await (getClient() as any) // untyped client
.from("games")
- .select("id, name, score, level, created_at")
+ .select("id, name, score, level, strategy, created_at")
.order("score", { ascending: false })
.order("created_at", { ascending: false })
.limit(LIMIT);
diff --git a/src/types/game.ts b/src/types/game.ts
index 5ed4082..6c6cc1b 100644
--- a/src/types/game.ts
+++ b/src/types/game.ts
@@ -8,6 +8,8 @@ export type Game = {
score: number;
max_time: number;
time_passed: number;
+ // Order strategy the board was dealt with. Historical rows default to 'random'.
+ strategy: string;
created_at: string;
};
diff --git a/src/utils/board-rules.ts b/src/utils/board-rules.ts
new file mode 100644
index 0000000..4c5bbec
--- /dev/null
+++ b/src/utils/board-rules.ts
@@ -0,0 +1,72 @@
+import type { GameBoard } from "@/types/game-board";
+
+/**
+ * Whether a tile may currently be picked up.
+ *
+ * This is the single source of truth for "freeness". Both the live game
+ * (`allowedforSelection` in the store) and the solvable-board generator depend
+ * on it, so they can never disagree about which tiles are reachable.
+ *
+ * Crucially, freeness is *purely geometric*: it looks only at a tile's
+ * layer/row/column and which tiles are still on the board — never at the token
+ * value. It is also *monotone*: removing tiles can only ever free more tiles,
+ * never cover one back up. Those two properties are what make
+ * solvability-by-construction possible (see init-gameboard.ts).
+ *
+ * A tile is selectable when:
+ * 1. No higher-layer tile in the same column covers it (same `row + layer`
+ * "topFactor" ⇒ visual overlap), and
+ * 2. It is exposed on a side — either a special-cased edge tile of the
+ * crooked layer-0 rows / the apex, or the left- or right-most remaining
+ * tile of its (row, layer).
+ *
+ * (The special-cased indices mirror the original board's hand-tuned edge cases;
+ * see the Excalidraw diagram referenced in the store.)
+ */
+export const isSelectable = (board: GameBoard, index: string): boolean => {
+ const tile = board[index];
+ if (!tile) return false;
+ const { row, layer, column } = tile;
+
+ // 1. Covered by a higher tile in the same column with matching topFactor?
+ const isCovered = Object.keys(board).some(
+ (j) =>
+ board[j].layer > layer &&
+ board[j].column === column &&
+ board[j].row + board[j].layer === row + layer
+ );
+ if (isCovered) return false;
+
+ // 2. Exposed on a side.
+ switch (true) {
+ // layer 0 hand-tuned edge cases
+ case index === "30": // left-side top-left
+ return board["42"] === undefined;
+ case index === "41": // right-side top-right
+ return board["55"] === undefined;
+ case index === "42" || index === "56": // outermost crooked tiles
+ return true;
+ case index === "43": // left-side top-right
+ return board["42"] === undefined;
+ case index === "54": // right-side bottom-right
+ return board["55"] === undefined;
+ case index === "55": // right-side left crooked
+ return board["56"] === undefined;
+ // layer 3 — covered by the apex (tile 143) which lives in a sentinel column
+ case index === "139" ||
+ index === "140" ||
+ index === "141" ||
+ index === "142":
+ return board["143"] === undefined;
+ default: {
+ const rowItems = Object.keys(board).filter(
+ (i) => board[i].row === row && board[i].layer === layer
+ );
+ return (
+ index === rowItems[rowItems.length - 1] || index === rowItems[0]
+ );
+ }
+ }
+};
+
+export default isSelectable;
diff --git a/src/utils/init-gameboard.ts b/src/utils/init-gameboard.ts
index 0aa36ec..76c6328 100644
--- a/src/utils/init-gameboard.ts
+++ b/src/utils/init-gameboard.ts
@@ -1,14 +1,106 @@
import type { Token, GameBoard } from "@/types/game-board";
-import { allTokens, gameBoardLayout } from "@/types/game-board";
-
-// Shuffles `allTokens` in a flat array using the Fisher-Yates algorithm.
-export const shuffleTiles = (): Token[] => {
- const array = [...allTokens];
- for (let i = array.length - 1; i > 0; i--) {
- const randomIndex = Math.floor(Math.random() * (i + 1));
- [array[i], array[randomIndex]] = [array[randomIndex], array[i]];
+import { tokens, allTokens, gameBoardLayout } from "@/types/game-board";
+import { shuffleInPlace } from "@/utils/shuffle";
+import {
+ getOrderStrategy,
+ fallbackStrategy,
+ type OrderStrategy,
+ type RemovalOrder,
+} from "@/utils/order-strategies";
+
+/**
+ * Why this file exists in this shape
+ * ----------------------------------
+ * Dropping all 144 tokens onto the fixed turtle layout at random — the obvious
+ * approach — frequently produces *unsolvable* boards: the player runs into a
+ * deadlock where tiles remain but no two reachable tiles match. Whether a board
+ * can be won at all becomes luck of the draw, which is exactly what we don't
+ * want in a game meant to reward skill.
+ *
+ * The fix is "solvability by construction": instead of dealing tokens and
+ * *hoping* a solution exists, we generate the board by recording one.
+ *
+ * 1. Peel the layout into a valid removal order — repeatedly take two
+ * currently-free positions and remove them, until the board is empty.
+ * Because freeness is purely geometric and monotone (see board-rules.ts),
+ * this only depends on positions, not tokens, and always empties the board.
+ * 2. Lay matched token pairs onto that removal order. Replaying the order then
+ * wins the game, so the board is guaranteed solvable — for *every* token
+ * assignment, since tokens never affect freeness.
+ *
+ * Boards stay fully varied (the peel order and token assignment are randomised
+ * per deal); only the *guarantee* of solvability is new.
+ *
+ * Step 1's policy lives behind a port — see order-strategies.ts — so the peel
+ * order can be experimented with locally without touching this construction.
+ */
+
+// A deterministic order of the (constant) layout, computed once via the
+// fallback strategy. Used if a chosen strategy strands. If this ever throws,
+// the layout itself is not fully clearable and the game is broken at the root.
+let fallbackOrder: RemovalOrder | undefined;
+const guaranteedOrder = (): RemovalOrder => {
+ if (fallbackOrder === undefined) {
+ const order = fallbackStrategy.peel(() => 0);
+ if (!order) {
+ throw new Error(
+ "gameBoardLayout is not fully clearable under the current selection rules"
+ );
+ }
+ fallbackOrder = order;
+ }
+ return fallbackOrder;
+};
+
+export type SolvableDeal = {
+ board: GameBoard;
+ /** The removal order used to build the board — replaying it wins the game. */
+ solution: RemovalOrder;
+};
+
+/**
+ * Deal a board that is guaranteed solvable, together with a winning solution.
+ * `random` is injectable for deterministic tests; production uses Math.random.
+ * `strategy` selects the peel order policy; it defaults to the resolved active
+ * strategy (production default unless overridden locally — see getOrderStrategy).
+ */
+export const dealSolvableBoard = (
+ level = 1,
+ random: () => number = Math.random,
+ strategy: OrderStrategy = getOrderStrategy()
+): SolvableDeal => {
+ let pairs: RemovalOrder | null = null;
+ for (let attempt = 0; attempt < 25 && !pairs; attempt++) {
+ pairs = strategy.peel(random);
+ }
+ if (!pairs) pairs = guaranteedOrder();
+
+ // 72 entries — each of the 36 tokens twice — so every token ends up on
+ // exactly 4 tiles (two pairs), matching `allTokens`.
+ const pairTokens = shuffleInPlace([...tokens, ...tokens], random);
+
+ const board: GameBoard = {};
+ const solution: Array<[string, string]> = [];
+ pairs.forEach(([a, b], i) => {
+ const token: Token = pairTokens[i];
+ board[a] = { ...gameBoardLayout[a], token, active: false, animating: null, grace: false };
+ board[b] = { ...gameBoardLayout[b], token, active: false, animating: null, grace: false };
+ solution.push([a, b]);
+ });
+
+ // From level 2 onward, mark `level - 1` random tiles as grace tiles. Grace
+ // only *adds* selectability (it bypasses the position rules), so it can never
+ // turn a solvable board unsolvable.
+ if (level >= 2) {
+ const graceCount = Math.min(level - 1, Object.keys(board).length);
+ shuffleInPlace(Object.keys(board), random)
+ .slice(0, graceCount)
+ .forEach((idx) => {
+ board[idx] = { ...board[idx], grace: true };
+ });
}
- return array;
+
+ return { board, solution };
};
// used for testing purposes, only initializes the first two tiles
@@ -21,34 +113,9 @@ export const initializeTestGameBoard = (): GameBoard => {
} as GameBoard;
};
-// Initializes the game board by filling it with shuffled tokens.
-// From level 2 onward, `level` random tiles are marked as grace tiles.
-export const initializeGameBoard = (level = 1): GameBoard => {
- const result: GameBoard = {};
-
- shuffleTiles().forEach((token, index) => {
- result[String(index)] = {
- ...gameBoardLayout[index],
- active: false,
- animating: null,
- grace: false,
- token,
- };
- });
-
- if (level >= 2) {
- const graceCount = level - 1;
- const indices = Object.keys(result);
- for (let i = indices.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [indices[i], indices[j]] = [indices[j], indices[i]];
- }
- indices.slice(0, graceCount).forEach((idx) => {
- result[idx] = { ...result[idx], grace: true };
- });
- }
-
- return result;
-};
+// Initializes a guaranteed-solvable game board, filled with matched token pairs.
+// From level 2 onward, `level - 1` tiles are marked as grace tiles.
+export const initializeGameBoard = (level = 1): GameBoard =>
+ dealSolvableBoard(level).board;
export default initializeGameBoard;
diff --git a/src/utils/order-strategies.test.ts b/src/utils/order-strategies.test.ts
new file mode 100644
index 0000000..db6b97d
--- /dev/null
+++ b/src/utils/order-strategies.test.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect, afterEach } from "vitest";
+import type { GameBoard } from "@/types/game-board";
+import { isSelectable } from "@/utils/board-rules";
+import { dealSolvableBoard } from "@/utils/init-gameboard";
+import {
+ STRATEGIES,
+ DEFAULT_STRATEGY,
+ canonical,
+ fallbackStrategy,
+ getOrderStrategy,
+ setOrderStrategy,
+ listOrderStrategies,
+} from "@/utils/order-strategies";
+
+const solutionWins = (board: GameBoard, solution: Array<[string, string]>) => {
+ const w: GameBoard = structuredClone(board);
+ for (const [a, b] of solution) {
+ if (!w[a] || !w[b]) return false;
+ if (w[a].token !== w[b].token) return false;
+ if (!isSelectable(w, a) || !isSelectable(w, b)) return false;
+ delete w[a];
+ delete w[b];
+ }
+ return Object.keys(w).length === 0;
+};
+
+describe("order strategies (port/adapter)", () => {
+ afterEach(() => setOrderStrategy(null)); // never leak an override between tests
+
+ it("every selectable strategy (and the fallback) deals solvable boards", () => {
+ for (const strategy of [...Object.values(STRATEGIES), fallbackStrategy]) {
+ for (let i = 0; i < 12; i++) {
+ const { board, solution } = dealSolvableBoard(3, Math.random, strategy);
+ expect(solutionWins(board, solution), `${strategy.name} #${i}`).toBe(true);
+ }
+ }
+ }, 30000);
+
+ it("canonical (fallback) is deterministic; topDownRandom varies", () => {
+ const c1 = canonical.peel(() => 0)!.map((p) => p.join("-")).join(",");
+ const c2 = canonical.peel(() => 0)!.map((p) => p.join("-")).join(",");
+ expect(c1).toEqual(c2);
+
+ const r1 = STRATEGIES.topDownRandom.peel(Math.random)!.map((p) => p.join("-")).join(",");
+ const r2 = STRATEGIES.topDownRandom.peel(Math.random)!.map((p) => p.join("-")).join(",");
+ expect(r1).not.toEqual(r2);
+ });
+
+ it("resolves to the production default unless overridden", () => {
+ expect(getOrderStrategy().name).toBe(DEFAULT_STRATEGY);
+ expect(DEFAULT_STRATEGY).toBe("scatter");
+
+ setOrderStrategy("original");
+ expect(getOrderStrategy().name).toBe("original");
+
+ setOrderStrategy("canonical"); // not selectable ⇒ ignored → default
+ expect(getOrderStrategy().name).toBe(DEFAULT_STRATEGY);
+
+ setOrderStrategy("nonsense"); // unknown name is ignored → default
+ expect(getOrderStrategy().name).toBe(DEFAULT_STRATEGY);
+
+ setOrderStrategy(null);
+ expect(getOrderStrategy().name).toBe(DEFAULT_STRATEGY);
+ });
+
+ it("exposes a strategy catalogue without the internal fallback", () => {
+ const names = listOrderStrategies().map((s) => s.name);
+ expect(names).toContain("topDownRandom");
+ expect(names).toContain("original");
+ expect(names).not.toContain("canonical");
+ expect(listOrderStrategies().every((s) => s.description.length > 0)).toBe(true);
+ });
+
+ // Informational: how often each policy strands (and leans on the fallback).
+ // Not an assertion — just a lens for local experimentation.
+ it("reports strand rates per strategy", () => {
+ const N = 40;
+ for (const strategy of Object.values(STRATEGIES)) {
+ let stranded = 0;
+ for (let i = 0; i < N; i++) if (strategy.peel(Math.random) === null) stranded++;
+ console.log(` ${strategy.name.padEnd(16)} strands ${((100 * stranded) / N).toFixed(1)}% of peels`);
+ expect(stranded).toBeLessThanOrEqual(N);
+ }
+ }, 20000);
+});
diff --git a/src/utils/order-strategies.ts b/src/utils/order-strategies.ts
new file mode 100644
index 0000000..eec01c4
--- /dev/null
+++ b/src/utils/order-strategies.ts
@@ -0,0 +1,252 @@
+import type { GameBoard } from "@/types/game-board";
+import { gameBoardLayout } from "@/types/game-board";
+import { isSelectable } from "@/utils/board-rules";
+import { shuffleInPlace } from "@/utils/shuffle";
+
+/**
+ * Order strategies — a ports & adapters seam for "how the layout is peeled
+ * into a removal order".
+ *
+ * The board generator (init-gameboard.ts) is solvable *by construction*: it
+ * peels the fixed layout into a sequence of free pairs, then lays matched
+ * tokens onto that sequence. Which pairs come out, and in what order, is the
+ * one interesting variable to experiment with — so it lives behind a port.
+ *
+ * THE PORT — `OrderStrategy.peel(random) => RemovalOrder | null`.
+ * AN ADAPTER — any policy for choosing the order. The default reproduces the
+ * current production behaviour exactly; others are for local
+ * experimentation (see `getOrderStrategy` resolution below).
+ *
+ * Solvability is preserved for *every* adapter, because the shared loop only
+ * ever removes tiles that are currently free (see `peelWith`). The only way a
+ * policy can misbehave is to *strand* (leave <2 free tiles with tiles still on
+ * the board); the loop returns `null` in that case and the caller falls back to
+ * the canonical adapter. So experiments can only change the *distribution* of
+ * boards, never whether a board is winnable.
+ */
+
+/** An ordered list of position-pairs; replaying it (matching, in order) wins. */
+export type RemovalOrder = Array<[string, string]>;
+
+/** Chooses the next two free tiles to remove. The two returned indices must be
+ * members of `free` (both are guaranteed free at this step). */
+export type PairSelector = (
+ free: string[],
+ board: GameBoard,
+ random: () => number
+) => [string, string];
+
+export type OrderStrategy = {
+ readonly name: string;
+ readonly description: string;
+ /** Produce a valid removal order, or `null` if this policy stranded. */
+ peel: (random: () => number) => RemovalOrder | null;
+};
+
+// A throwaway board holding every layout position, used only while peeling.
+const buildLayoutBoard = (): GameBoard => {
+ const board: GameBoard = {};
+ for (const idx of Object.keys(gameBoardLayout)) {
+ board[idx] = {
+ ...gameBoardLayout[idx],
+ token: "0",
+ active: false,
+ animating: null,
+ grace: false,
+ };
+ }
+ return board;
+};
+
+/**
+ * Build a strategy from a per-step `PairSelector`. The peel loop, the freeness
+ * check, and the stranding guard are shared here, so an adapter only has to
+ * express its *policy* — which two free tiles to take next.
+ */
+export const peelWith = (
+ name: string,
+ description: string,
+ select: PairSelector
+): OrderStrategy => ({
+ name,
+ description,
+ peel(random) {
+ const board = buildLayoutBoard();
+ const order: RemovalOrder = [];
+ while (Object.keys(board).length > 0) {
+ const free = Object.keys(board).filter((i) => isSelectable(board, i));
+ if (free.length < 2) return null; // stranded — caller retries / falls back
+ const [a, b] = select(free, board, random);
+ order.push([a, b]);
+ delete board[a];
+ delete board[b];
+ }
+ return order;
+ },
+});
+
+/* ------------------------------------------------------------------ adapters */
+
+/**
+ * DEFAULT (production). Greedy: remove the two highest-layer free tiles, with a
+ * random tie-break within a layer. Clearing the top first keeps a lingering
+ * high tile from stranding the tiles beneath it; the random tie-break is what
+ * varies the pairing — and therefore the board — between deals.
+ */
+export const topDownRandom = peelWith(
+ "topDownRandom",
+ "Greedy top-down, random tie-break within a layer (production default).",
+ (free, board, random) => {
+ shuffleInPlace(free, random);
+ // Stable sort by layer (desc) keeps the random order within each layer.
+ free.sort((a, b) => board[b].layer - board[a].layer);
+ return [free[0], free[1]];
+ }
+);
+
+/**
+ * Original / loose: pick any two free tiles with no layer preference. Produces
+ * the most varied pairings but strands more often (the generator then leans on
+ * the internal fallback), so it's a good lens for comparing distributions.
+ */
+export const original = peelWith(
+ "original",
+ "Pick any two free tiles at random — no layer preference, the loosest pairing.",
+ (free, _board, random) => {
+ const i = Math.floor(random() * free.length);
+ let j = Math.floor(random() * (free.length - 1));
+ if (j >= i) j++;
+ return [free[i], free[j]];
+ }
+);
+
+/**
+ * Deterministic top-down: highest layer, then lowest index. Ignores `random`,
+ * so it yields the *same* removal order every run and never strands. Not a
+ * selectable strategy — kept purely as the guaranteed fallback (see below).
+ */
+export const canonical = peelWith(
+ "canonical",
+ "Deterministic top-down (highest layer, lowest index). Internal fallback.",
+ (free, board) => {
+ const sorted = [...free].sort(
+ (a, b) => board[b].layer - board[a].layer || Number(a) - Number(b)
+ );
+ return [sorted[0], sorted[1]];
+ }
+);
+
+/**
+ * Bottom-up: prefer the *lowest*-layer free tiles. The mirror image of the
+ * default — a useful contrast to see how peel direction shapes the boards (and
+ * how often a policy ends up leaning on the fallback).
+ */
+export const bottomUpRandom = peelWith(
+ "bottomUpRandom",
+ "Greedy bottom-up, random tie-break within a layer.",
+ (free, board, random) => {
+ shuffleInPlace(free, random);
+ free.sort((a, b) => board[a].layer - board[b].layer);
+ return [free[0], free[1]];
+ }
+);
+
+/**
+ * Scatter: pair the highest free tile with the lowest free tile each step.
+ * Removing a top tile every step keeps the peel from stranding (covers cleared
+ * promptly), while pairing it with a base tile spreads matches *across* layers.
+ * This avoids the "ready-made" look the layer-greedy strategies produce, where
+ * the small upper layers get paired within themselves and sit as obvious
+ * matched pairs at the top of the pyramid.
+ */
+export const scatter = peelWith(
+ "scatter",
+ "Pair the highest free tile with the lowest — matches spread across layers (no ready-made clusters).",
+ (free, board, random) => {
+ shuffleInPlace(free, random); // randomise ties within a layer
+ const byLayer = [...free].sort((a, b) => board[b].layer - board[a].layer);
+ return [byLayer[0], byLayer[byLayer.length - 1]]; // highest + lowest
+ }
+);
+
+/* ------------------------------------------------------------------ registry */
+
+// Selectable strategies (shown in the switcher / catalogue). `canonical` is
+// deliberately excluded — it's the deterministic safety net, not a play option.
+export const STRATEGIES: Record = {
+ topDownRandom,
+ scatter,
+ original,
+ bottomUpRandom,
+};
+
+// `scatter` is the default: layer-greedy strategies (topDown/bottomUp) always
+// pair the top-of-pyramid tiles among themselves, which reads as a "ready-made"
+// cluster of matching pairs at the top. scatter spreads matches across layers.
+export const DEFAULT_STRATEGY = "scatter";
+
+/** Guaranteed to complete (never strands). Used when a selectable strategy strands. */
+export const fallbackStrategy: OrderStrategy = canonical;
+
+export const listOrderStrategies = (): Array<{ name: string; description: string }> =>
+ Object.values(STRATEGIES).map((s) => ({ name: s.name, description: s.description }));
+
+/* ----------------------------------------------------- local experimentation */
+
+// Strategy switching is a development-only affordance. In production the
+// strategy is operator-controlled (env or default) so that highscores stay
+// comparable and players can't change the board distribution they're scored on.
+const DEV = process.env.NODE_ENV !== "production";
+
+let runtimeOverride: string | null = null;
+
+/**
+ * Override the active strategy for this session. Pass a name from `STRATEGIES`,
+ * or `null` to clear. Persists to localStorage (when available) so it survives
+ * a reload; the next board dealt uses it.
+ */
+export const setOrderStrategy = (name: string | null): void => {
+ runtimeOverride = name && STRATEGIES[name] ? name : null;
+ try {
+ if (typeof localStorage !== "undefined") {
+ if (runtimeOverride) localStorage.setItem("eureka.orderStrategy", runtimeOverride);
+ else localStorage.removeItem("eureka.orderStrategy");
+ }
+ } catch {
+ /* localStorage unavailable (SSR / privacy mode) — in-memory override still applies */
+ }
+};
+
+/**
+ * Resolve the active strategy.
+ * Production: `NEXT_PUBLIC_ORDER_STRATEGY` env → DEFAULT_STRATEGY. Player
+ * overrides (runtime / localStorage) are ignored.
+ * Development: runtime override → localStorage `eureka.orderStrategy` → env →
+ * DEFAULT_STRATEGY, re-read every deal so live switching works.
+ */
+export const getOrderStrategy = (): OrderStrategy => {
+ const fromEnv =
+ typeof process !== "undefined" ? process.env?.NEXT_PUBLIC_ORDER_STRATEGY : undefined;
+ let name = fromEnv ?? DEFAULT_STRATEGY;
+ if (DEV) {
+ let fromStorage: string | null = null;
+ try {
+ if (typeof localStorage !== "undefined") fromStorage = localStorage.getItem("eureka.orderStrategy");
+ } catch {
+ /* ignore */
+ }
+ name = runtimeOverride ?? fromStorage ?? fromEnv ?? DEFAULT_STRATEGY;
+ }
+ return STRATEGIES[name] ?? STRATEGIES[DEFAULT_STRATEGY];
+};
+
+// Dev convenience: expose the knobs on window so you can experiment from the
+// browser console, e.g. `eurekaOrder.set('original')` then start a new game.
+// Not exposed in production — the strategy is locked there.
+if (DEV && typeof window !== "undefined") {
+ (window as unknown as { eurekaOrder?: unknown }).eurekaOrder = {
+ set: setOrderStrategy,
+ list: listOrderStrategies,
+ current: () => getOrderStrategy().name,
+ };
+}
diff --git a/src/utils/post-game-state.ts b/src/utils/post-game-state.ts
index f1d9cd4..d9cf6d3 100644
--- a/src/utils/post-game-state.ts
+++ b/src/utils/post-game-state.ts
@@ -9,6 +9,7 @@ export interface GameStatePayload {
score: number;
maxTime: number;
timePassed: number;
+ strategy: string;
}
export const postGameState = async (
@@ -25,6 +26,7 @@ export const postGameState = async (
score: gameState.score,
max_time: gameState.maxTime,
time_passed: gameState.timePassed,
+ strategy: gameState.strategy,
}),
});
diff --git a/src/utils/shuffle.ts b/src/utils/shuffle.ts
new file mode 100644
index 0000000..e63004c
--- /dev/null
+++ b/src/utils/shuffle.ts
@@ -0,0 +1,14 @@
+// Fisher-Yates, in place. Randomness is injected so callers can be made
+// deterministic (e.g. tests, or the canonical fallback that passes a constant).
+export const shuffleInPlace = (
+ array: T[],
+ random: () => number = Math.random
+): T[] => {
+ for (let i = array.length - 1; i > 0; i--) {
+ const j = Math.floor(random() * (i + 1));
+ [array[i], array[j]] = [array[j], array[i]];
+ }
+ return array;
+};
+
+export default shuffleInPlace;
diff --git a/src/utils/solvable-board.test.ts b/src/utils/solvable-board.test.ts
new file mode 100644
index 0000000..0fe2234
--- /dev/null
+++ b/src/utils/solvable-board.test.ts
@@ -0,0 +1,78 @@
+import { describe, it, expect } from "vitest";
+import { tokens, gameBoardLayout } from "@/types/game-board";
+import type { GameBoard } from "@/types/game-board";
+import { dealSolvableBoard } from "@/utils/init-gameboard";
+import { isSelectable } from "@/utils/board-rules";
+
+/**
+ * Replays a claimed solution against the *real* selection rule. Returns true
+ * only if every pair is a legal match (both tiles present, both reachable in
+ * the same board state, same token) and the board ends up empty. This is the
+ * independent proof that a board is genuinely winnable.
+ */
+const solutionWins = (
+ board: GameBoard,
+ solution: Array<[string, string]>
+): boolean => {
+ const work: GameBoard = structuredClone(board);
+ for (const [a, b] of solution) {
+ if (!work[a] || !work[b]) return false;
+ if (work[a].token !== work[b].token) return false;
+ // Both must be reachable with both still on the board (you select one, then
+ // the other) — grace tiles aside, this is exactly what the game enforces.
+ const reachable = (idx: string) => work[idx].grace || isSelectable(work, idx);
+ if (!reachable(a) || !reachable(b)) return false;
+ delete work[a];
+ delete work[b];
+ }
+ return Object.keys(work).length === 0;
+};
+
+describe("solvable board generation", () => {
+ it("deals all 144 positions with the correct token multiset", () => {
+ const { board } = dealSolvableBoard();
+ expect(Object.keys(board)).toHaveLength(Object.keys(gameBoardLayout).length);
+
+ const counts = new Map();
+ for (const tile of Object.values(board)) {
+ counts.set(tile.token, (counts.get(tile.token) ?? 0) + 1);
+ }
+ expect(counts.size).toBe(tokens.length);
+ for (const token of tokens) {
+ expect(counts.get(token)).toBe(4); // 4 copies of every token
+ }
+ });
+
+ it("every dealt board is solvable across many random deals and levels", () => {
+ for (let level = 1; level <= 6; level++) {
+ for (let i = 0; i < 60; i++) {
+ const { board, solution } = dealSolvableBoard(level);
+ expect(solutionWins(board, solution)).toBe(true);
+ }
+ }
+ }, 30000);
+
+ it("is solvable even with a degenerate RNG (forces the fallback path)", () => {
+ // A constant RNG makes Fisher-Yates and tie-breaking trivial; the board must
+ // still be solvable, exercising the deterministic peel/fallback.
+ const { board, solution } = dealSolvableBoard(1, () => 0);
+ expect(solutionWins(board, solution)).toBe(true);
+ });
+
+ it("produces varied boards between deals", () => {
+ const fingerprint = (b: GameBoard) =>
+ Object.keys(b)
+ .sort((a, c) => Number(a) - Number(c))
+ .map((k) => b[k].token)
+ .join("");
+ const a = fingerprint(dealSolvableBoard().board);
+ const b = fingerprint(dealSolvableBoard().board);
+ expect(a).not.toEqual(b);
+ });
+
+ it("grace tiles never break solvability", () => {
+ const { board, solution } = dealSolvableBoard(5);
+ expect(Object.values(board).filter((t) => t.grace)).toHaveLength(4);
+ expect(solutionWins(board, solution)).toBe(true);
+ });
+});
diff --git a/src/zustand/game-store.test.ts b/src/zustand/game-store.test.ts
index bf1f2f8..ffe8220 100644
--- a/src/zustand/game-store.test.ts
+++ b/src/zustand/game-store.test.ts
@@ -9,6 +9,15 @@ vi.mock("@/utils/post-game-state", () => ({
postGameState: vi.fn().mockResolvedValue({ id: "mock-game-id" }),
}));
+// start() spins a real 1s interval on the singleton store. beforeEach resets
+// state but never clears live intervals, so they leak across tests and keep
+// firing step() on the shared store — which made the autosave test flaky on CI.
+// Clear any live timer after every test in this file.
+afterEach(() => {
+ const { timer } = useGameStore.getState();
+ if (timer) clearInterval(timer);
+ useGameStore.setState({ timer: null });
+});
describe("useGameStore", () => {
beforeEach(() => {
@@ -836,30 +845,38 @@ describe("Game State Saving", () => {
});
it("autosaves every 60 seconds", async () => {
- const { result } = renderHook(() => useGameStore());
+ // Fake timers so the live 1s interval that start() creates can't fire an
+ // extra step() on a slow machine — which would push timePassed to 60 and
+ // trip the autosave early. The test drives time by calling step() manually.
+ vi.useFakeTimers();
+ try {
+ const { result } = renderHook(() => useGameStore());
- await act(async () => {
- result.current.start();
- await Promise.resolve();
- });
+ await act(async () => {
+ result.current.start();
+ await Promise.resolve();
+ });
- expect(postGameState).not.toHaveBeenCalled();
+ expect(postGameState).not.toHaveBeenCalled();
- await act(async () => {
- for (let i = 0; i < 59; i++) {
- result.current.step();
- }
- await Promise.resolve();
- });
+ await act(async () => {
+ for (let i = 0; i < 59; i++) {
+ result.current.step();
+ }
+ await Promise.resolve();
+ });
- expect(postGameState).not.toHaveBeenCalled();
+ expect(postGameState).not.toHaveBeenCalled();
- await act(async () => {
- result.current.step();
- await Promise.resolve();
- });
+ await act(async () => {
+ result.current.step();
+ await Promise.resolve();
+ });
- expect(postGameState).toHaveBeenCalled();
+ expect(postGameState).toHaveBeenCalled();
+ } finally {
+ vi.useRealTimers();
+ }
});
it("resets gameId when starting a new game to null", async () => {
diff --git a/src/zustand/game-store.ts b/src/zustand/game-store.ts
index b974901..b230fe2 100644
--- a/src/zustand/game-store.ts
+++ b/src/zustand/game-store.ts
@@ -2,6 +2,8 @@ import { create } from "zustand";
import { devtools } from "zustand/middleware";
import type { GameBoard } from "@/types/game-board";
import { initializeGameBoard } from "@/utils/init-gameboard";
+import { isSelectable } from "@/utils/board-rules";
+import { getOrderStrategy, DEFAULT_STRATEGY } from "@/utils/order-strategies";
import { postGameState } from "@/utils/post-game-state";
export type Timer = ReturnType | null;
@@ -37,6 +39,9 @@ export type State = {
// True from the moment restart() clears the board until start() finishes
// initialising the new board. Used to show a loading indicator in GameBoard.
isRestarting: boolean;
+ // The order strategy the current board was dealt with. Saved with the game so
+ // highscores record which strategy produced the board (historical rows: 'random').
+ strategy: string;
};
export type Action = {
@@ -55,6 +60,7 @@ export type Action = {
scoredPair: () => Promise;
levelCleared: () => Promise;
continueNextLevel: () => void;
+ redealCurrentLevel: () => void;
endGame: () => Promise;
changeName: (name: string) => void;
withdraw: () => Promise;
@@ -86,6 +92,7 @@ const initialState: State = {
boardGeneration: 0, // 0 = no game started yet; Date.now() once a game begins
shouldAnimateOnMount: false,
isRestarting: false,
+ strategy: DEFAULT_STRATEGY,
};
// Serialises all saves so concurrent calls never race on gameId.
@@ -151,6 +158,7 @@ export const useGameStore = create()(
set((prev) => ({
...initialState,
gameBoard: initializeGameBoard(),
+ strategy: getOrderStrategy().name,
name: prev.name,
boardGeneration: Date.now(),
shouldAnimateOnMount: true,
@@ -208,68 +216,9 @@ export const useGameStore = create()(
},
- allowedforSelection: (index: string) => {
- const { gameBoard: board } = get();
- const { row, layer, column } = board[index];
-
- // Check for a tile on a higher layer that visually covers the clicked tile.
- // Two tiles overlap vertically when row_j + layer_j === row + layer
- // (both map to the same topFactor). Only the same column can cover a tile
- // because adjacent columns are spaced 1.02× tile-width apart — no visual overlap.
- const coveringItem = (row: number, layer: number, column: number) => {
- return Object.keys(board).filter((j) => {
- return (
- board[j].layer > layer &&
- board[j].column === column &&
- board[j].row + board[j].layer === row + layer
- );
- });
- };
-
- const rowItems = (row: number, layer: number) => {
- return Object.keys(board)
- .filter((i) => {
- return board[i].row === row && board[i].layer == layer;
- });
- };
-
- // Check if covering tile exists
- if (coveringItem(row, layer, column).length !== 0) {
- return false;
- };
-
- switch (true) {
- // layer 0
- // see Excalidraw diagram for level 0 edge cases
- // 30 == left-side top-left
- case index === "30":
- return board[42] === undefined;
- // 41 == right-side top-right
- case index === "41":
- return board[55] === undefined;
- // 42 == left-side left crooked
- case index === "42" || index === "56":
- // outermost crooked items, just for documentation sake
- return true;
- // 43 == left-side top-right
- case index === "43":
- return board[42] === undefined;
- // 54 == right-side bottom-right
- case index === "54":
- return board[55] === undefined;
- case index === "55": // right-side left crooked
- return board[56] === undefined;
- // layer 3
- case [139, 140, 141, 142].map(String).includes(index):
- return board[143] === undefined;
- default:
- const items = rowItems(row, layer);
- return (
- index === String(items[items.length - 1]) ||
- index === String(items[0])
- );
- }
- },
+ // Freeness is purely geometric, so it lives in a shared pure helper that
+ // the solvable-board generator reuses — keeping game and generator in sync.
+ allowedforSelection: (index: string) => isSelectable(get().gameBoard, index),
clicked: async (index: string) => {
set((state) => {
@@ -376,12 +325,25 @@ export const useGameStore = create()(
set(() => ({
levelClear: false,
gameBoard: newBoard,
+ strategy: getOrderStrategy().name,
boardGeneration: Date.now(),
shouldAnimateOnMount: true,
timer: globalThis.setInterval(() => get().step(), EVERY_SECOND),
}));
},
+ // Re-deal the current level's board with the active order strategy,
+ // keeping level/score/time. Used by the dev strategy switcher so a
+ // strategy change takes effect on the board immediately.
+ redealCurrentLevel: () => {
+ set((state) => ({
+ gameBoard: initializeGameBoard(state.level),
+ strategy: getOrderStrategy().name,
+ boardGeneration: Date.now(),
+ shouldAnimateOnMount: true,
+ }));
+ },
+
endGame: async () => {
// Save first, then look up rank, then set gameOver + both rank fields
// atomically. This ensures the game-over page always mounts with the
@@ -446,6 +408,7 @@ export const useGameStore = create()(
score: state.score,
maxTime: state.maxTime,
timePassed: state.timePassed,
+ strategy: state.strategy,
});
set({ gameId: savedGame.id });
} catch (error) {
diff --git a/supabase/migrations/20260525_add_strategy_to_games.sql b/supabase/migrations/20260525_add_strategy_to_games.sql
new file mode 100644
index 0000000..2bd1659
--- /dev/null
+++ b/supabase/migrations/20260525_add_strategy_to_games.sql
@@ -0,0 +1,8 @@
+-- Record the order strategy each game's board was dealt with.
+--
+-- Rows created before solvability-by-construction shipped were dealt with a
+-- plain random fill, so they backfill to 'random' via the column default.
+-- New games record the actual strategy (e.g. 'scatter'). The default also keeps
+-- inserts from the previously-deployed client (which omits the column) valid.
+-- Idempotent so it is safe to re-run.
+ALTER TABLE games ADD COLUMN IF NOT EXISTS strategy text NOT NULL DEFAULT 'random';