Skip to content
Merged
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
Binary file added docs/gifs/construction.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/gifs/freeness.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/gifs/random.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
363 changes: 363 additions & 0 deletions docs/solvable-boards.html

Large diffs are not rendered by default.

326 changes: 326 additions & 0 deletions docs/strategy-difficulty-report.html

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/app/play/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import GameBoard from "@/components/game-board";
import { useGameStore } from "@/zustand/game-store";
import { useRouter } from "next/navigation";
import RankToast from "@/components/rank-toast";
import StrategySwitcher from "@/components/strategy-switcher";

export default function Game() {
const gameOver = useGameStore((state) => state.gameOver);
Expand All @@ -28,6 +29,7 @@ export default function Game() {
<GameBoard />
</div>
<RankToast />
<StrategySwitcher />
</div>
);
}
27 changes: 19 additions & 8 deletions src/components/leaderboard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export interface HighscoreRow {
level: number;
created_at: string;
rank: number;
// Order strategy the board was dealt with (historical rows: 'random').
strategy?: string;
}

const LIMIT = 10;
Expand All @@ -35,6 +37,7 @@ const Leaderboard = ({ initial }: LeaderboardProps) => {
const name = useGameStore((s) => s.name);
const score = useGameStore((s) => s.score);
const level = useGameStore((s) => s.level);
const strategy = useGameStore((s) => s.strategy);

const { rows, youId } = useMemo(() => {
const merged: Omit<HighscoreRow, "rank">[] = initial.map((r) => ({
Expand All @@ -43,13 +46,14 @@ const Leaderboard = ({ initial }: LeaderboardProps) => {
score: r.score,
level: r.level,
created_at: r.created_at,
strategy: r.strategy,
}));

let you: string | null = null;
if (gameId && score > 0) {
you = gameId;
if (!merged.some((r) => r.id === gameId)) {
merged.push({ id: gameId, name, score, level, created_at: new Date().toISOString() });
merged.push({ id: gameId, name, score, level, created_at: new Date().toISOString(), strategy });
}
}

Expand All @@ -63,7 +67,7 @@ const Leaderboard = ({ initial }: LeaderboardProps) => {
if (you && !ranked.some((r) => r.id === you)) you = null;

return { rows: ranked, youId: you };
}, [initial, gameId, name, score, level]);
}, [initial, gameId, name, score, level, strategy]);

return (
<div className="w-full sm:w-[80vw] rounded-2xl overflow-hidden shadow-2xl border border-slate-600 flex flex-col flex-1 min-h-0">
Expand Down Expand Up @@ -99,13 +103,20 @@ const Leaderboard = ({ initial }: LeaderboardProps) => {
<span className="text-slate-500 text-xl sm:text-3xl font-bold">{entry.rank}</span>
)}
</span>
<span className="flex items-center gap-2 min-w-0 pr-2">
<span className="text-white font-medium truncate text-sm sm:text-lg">
{entry.name || <span className="text-slate-500 italic">Anonymous</span>}
<span className="flex flex-col justify-center min-w-0 pr-2">
<span className="flex items-center gap-2 min-w-0">
<span className="text-white font-medium truncate text-sm sm:text-lg">
{entry.name || <span className="text-slate-500 italic">Anonymous</span>}
</span>
{isYou && (
<span className="shrink-0 rounded-full bg-cyan-400/90 text-slate-900 text-[10px] sm:text-xs font-bold px-2 py-0.5 uppercase tracking-wide">
You
</span>
)}
</span>
{isYou && (
<span className="shrink-0 rounded-full bg-cyan-400/90 text-slate-900 text-[10px] sm:text-xs font-bold px-2 py-0.5 uppercase tracking-wide">
You
{entry.strategy && (
<span className="text-[10px] sm:text-xs text-slate-500 font-mono tracking-wide truncate">
{entry.strategy}
</span>
)}
</span>
Expand Down
109 changes: 109 additions & 0 deletions src/components/strategy-switcher/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"use client";

import React, { useCallback, useEffect, useState } from "react";
import { useGameStore } from "@/zustand/game-store";
import {
getOrderStrategy,
listOrderStrategies,
setOrderStrategy,
} from "@/utils/order-strategies";

/**
* Dev-only affordance to compare board order strategies in-game.
* Press `s` (next) or `Shift+S` (previous) — or click the badge — to switch the
* peel strategy and re-deal the current level with it. Hidden in production
* builds; the strategy choice (localStorage) still persists across reloads.
*/
const enabled = process.env.NODE_ENV !== "production";

const StrategySwitcher: React.FC = () => {
const redeal = useGameStore((state) => state.redealCurrentLevel);
const [current, setCurrent] = useState<string>(() =>
enabled ? getOrderStrategy().name : ""
);

const cycle = useCallback(
(dir: number) => {
const names = listOrderStrategies().map((s) => s.name);
const idx = names.indexOf(getOrderStrategy().name);
const next = names[(idx + dir + names.length) % names.length];
setOrderStrategy(next);
setCurrent(next);
redeal();
},
[redeal]
);

useEffect(() => {
if (!enabled) return;
const isTyping = (el: EventTarget | null) => {
const node = el as HTMLElement | null;
if (!node) return false;
return (
node.tagName === "INPUT" ||
node.tagName === "TEXTAREA" ||
node.tagName === "SELECT" ||
node.isContentEditable
);
};
const onKey = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey || isTyping(e.target)) return;
if (e.key === "s") {
e.preventDefault();
cycle(1);
} else if (e.key === "S") {
e.preventDefault();
cycle(-1);
} else if (e.key === "r" || e.key === "R") {
// re-deal the *same* strategy — to watch a single strategy vary
e.preventDefault();
redeal();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [cycle, redeal]);

if (!enabled) return null;

const strategies = listOrderStrategies();

return (
<div className="group fixed bottom-3 left-3 z-50 select-none font-mono text-xs">
{/* hover explanation — lists every strategy, current one highlighted */}
<div className="pointer-events-none absolute bottom-full left-0 mb-2 w-72 origin-bottom-left scale-95 rounded-lg border border-white/15 bg-black/90 p-3 text-white/80 opacity-0 shadow-xl backdrop-blur transition-all duration-150 group-hover:scale-100 group-hover:opacity-100">
<div className="mb-2 text-[10px] uppercase tracking-widest text-white/40">
board order strategy
</div>
<ul className="space-y-2">
{strategies.map((s) => {
const active = s.name === current;
return (
<li key={s.name} className={active ? "text-amber-300" : "text-white/60"}>
<span className="font-semibold">{active ? "▸ " : " "}{s.name}</span>
<div className="pl-3 text-[11px] leading-snug text-white/45">{s.description}</div>
</li>
);
})}
</ul>
<div className="mt-2 border-t border-white/10 pt-2 text-[10px] leading-relaxed text-white/35">
s next · ⇧s prev · r re-deal (same strategy) · or click
<br />
note: strategies change the hidden solution, not the visible layout.
</div>
</div>

<button
type="button"
onClick={() => cycle(1)}
className="rounded-md border border-white/15 bg-black/70 px-3 py-2 text-white/80 backdrop-blur transition-colors hover:bg-black/80"
>
<span className="text-white/40">order</span>{" "}
<span className="text-amber-300">{current}</span>{" "}
<span className="text-white/30">· s / ⇧s</span>
</button>
</div>
);
};

export default StrategySwitcher;
3 changes: 2 additions & 1 deletion src/db/select-game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ interface RankedHighscore {
name: string;
score: number;
level: number;
strategy: string;
created_at: string;
rank: number;
}
Expand All @@ -14,7 +15,7 @@ const LIMIT = 10;
export const getHighscores = async (): Promise<RankedHighscore[]> => {
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);
Expand Down
2 changes: 2 additions & 0 deletions src/types/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
72 changes: 72 additions & 0 deletions src/utils/board-rules.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading