How we built a production-ready memory game where every card, theme, and hint is generated by LLMs — with zero hardcoded content.
The Premise
Classic memory match games ship with fixed card sets: animals, flags, emojis. Boring after the third play.
What if every single game session was unique? Enter a theme like "kawaii desserts in outer space" and an LLM designs 8 pairs, writes hints, picks gradients, and names the board — in seconds.
That's AI Memory Match: a Next.js 15 app where OpenRouter is the game engine, not a chat sidebar.
Architecture Overview
flowchart LR
subgraph Frontend
A[React 19 Client]
B[useGame Hook]
C[Framer Motion Cards]
end
subgraph Next.js Server
D[API Routes]
E[Server Actions]
F[openrouter.ts]
end
subgraph AI
G[OpenRouter Free Models]
end
A --> D
B -->|local only| B
D --> E --> F --> G
The app follows a strict separation:
- Client — rendering, animations, game state, LocalStorage
- Server — all LLM calls (API key never touches the browser)
- OpenRouter — model routing with automatic fallbacks
Tech Choices and Why
| Choice | Why |
|---|---|
| Next.js 15 App Router | Server Actions + API routes for secure AI calls; Vercel deploy in one click |
| No Vercel AI SDK | Direct fetch to OpenRouter's OpenAI-compatible endpoint — simpler, full control |
| Framer Motion | 3D card flips need preserve-3d + spring physics; CSS alone falls short |
| LocalStorage | Streaks and leaderboards without a database for v1; Supabase stub ready |
| OpenRouter free models | Curated collection — no credits required |
The AI Pipeline: From Theme to Playable Board
Problem: Single LLM calls are slow
Our first implementation sent one prompt asking for 16 cards. On openrouter/free, that took 77 seconds — and timed out server actions, surfacing as a cryptic "Fetch failed" in the browser.
Solution: Parallel batching
We split generation into three concurrent requests:
// Metadata: theme name, background, hints, card back design
const metadata = await generateMetadata(themeInput);
// Card batches: 4 pairs each (8 cards per batch)
const batchResults = await runWithConcurrency(batchPromises, 2);
sequenceDiagram
participant S as Server
participant M as Metadata LLM
participant B1 as Batch 1 LLM
participant B2 as Batch 2 LLM
par Parallel
S->>M: themeName, hintPool, backDesign
S->>B1: pairs a,b,c,d
S->>B2: pairs e,f,g,h
end
M-->>S: ~1KB JSON
B1-->>S: 8 cards
B2-->>S: 8 cards
S->>S: merge → GameData
Result: ~12 seconds on free models (down from 77s).
Prompt engineering for JSON reliability
Free models include reasoning variants that burn tokens on chain-of-thought and return empty content. We enforce:
export const JSON_SYSTEM_PROMPT =
"You are a game content generator. Return ONLY valid JSON. " +
"No markdown, no code fences, no reasoning traces, no chain-of-thought. " +
"Start your response with {";
// OpenRouter structured output
response_format: { type: "json_object" }
Card batch prompts keep fields short to avoid truncation:
const userPrompt = `Theme: "${themeInput}"
Create ${pairCount} unique pairs (${pairCount * 2} cards) using pairIds: ${pairIds.join(", ")}
Each pair: same pairId, 2 cards, similar-but-distinct imagePrompts.
Return: {"cards":[{"id":"1","pairId":"a","imagePrompt":"short","emoji":"🐱",...}]}`;
Model fallback chain
Models die. Free tiers rate-limit. We iterate through OpenRouter's ranked free collection:
export const FREE_TEXT_MODELS = [
"tencent/hy3:free",
"nvidia/nemotron-3-ultra-550b-a55b:free",
"poolside/laguna-m.1:free",
"cohere/north-mini-code:free",
// ...
"openrouter/free", // auto-router last resort
];
for (const model of modelsToTry) {
for (let attempt = 0; attempt < 2; attempt++) {
const response = await fetch(OPENROUTER_BASE + "/chat/completions", {
body: JSON.stringify({
model,
messages,
max_tokens: Math.max(baseTokens + attempt * 400, 1000),
response_format: { type: "json_object" },
}),
});
if (content?.trim()) return content;
}
}
On 404/429/empty, next model. On truncated JSON, we attempt repair:
function repairTruncatedJson(json: string): string | null {
let attempt = json;
for (let i = 0; i < 6; i++) {
try { JSON.parse(attempt); return attempt; }
catch { attempt = attempt.replace(/,?\s*[^,}\]]*$/, ""); }
}
return null;
}
Game State: A Simple State Machine
GameApp.tsx drives four phases:
type AppPhase = "landing" | "loading" | "playing" | "won";
stateDiagram-v2
landing --> loading : fetch /api/generate-game
loading --> playing : GameData received
loading --> landing : error toast
playing --> won : all pairs matched
won --> playing : play again (reshuffle)
won --> landing : new theme
Generation uses the API route (not a bare Server Action) for clearer timeouts:
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 110_000);
const response = await fetch("/api/generate-game", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ theme, difficulty, enableImageGen }),
signal: controller.signal,
});
The route sets export const maxDuration = 120 for Vercel Pro deployments.
Gameplay: useGame and the LLM Budget
Card flips are pure client state. No network. No LLM.
const handleCardClick = useCallback((index: number) => {
// flip logic, match detection, confetti on match
setStats((prev) => ({ ...prev, moves: prev.moves + 1 }));
}, [board, flippedIndices, isLocked]);
Hints: pool-first, AI-last
At game generation, the LLM produces hintPool: string[] (5–8 hints). During play:
| Trigger | LLM? | Mechanism |
|---|---|---|
| Every 4 misses | No |
pickPoolHint() from pre-generated array |
| Manual hint button | Pool first | Unused pool hints served instantly |
| Pool exhausted | Yes (max 2/game) |
POST /api/get-hint with board context |
// Auto hints — useEffect, NOT inside setState (avoids React Router error)
useEffect(() => {
if (misses % HINT_MISS_THRESHOLD === 0 && misses !== lastAutoHintMissRef.current) {
lastAutoHintMissRef.current = misses;
const poolHint = pickPoolHint();
if (poolHint) revealHint(poolHint);
}
}, [stats.misses]);
We hit a classic React bug: calling getHint() (a server action) inside a setStats updater triggered "Cannot update Router while rendering GameBoard". Moving hint logic to useEffect fixed it.
3D Card Flips with Framer Motion
<motion.div
className="card-inner relative h-full w-full"
animate={{ rotateY: isRevealed ? 180 : 0 }}
transition={{ duration: 0.6, type: "spring", stiffness: 200 }}
>
<div className="card-face absolute inset-0"> {/* back */} </div>
<div className="card-face card-back absolute inset-0"> {/* front */} </div>
</motion.div>
CSS essentials:
.card-perspective { perspective: 1000px; }
.card-inner { transform-style: preserve-3d; }
.card-face { backface-visibility: hidden; }
.card-back { transform: rotateY(180deg); }
Each card shows either an AI-generated image URL or a rich emoji + gradient fallback.
Persistence Layer
LocalStorage handles streaks, theme bests, and a local leaderboard:
export function updateStreakOnWin(): PlayerProgress {
const today = new Date().toISOString().split("T")[0];
const yesterday = new Date(Date.now() - 86400000).toISOString().split("T")[0];
let newStreak = progress.streak;
if (progress.lastPlayedDate === yesterday) newStreak += 1;
else if (progress.lastPlayedDate !== today) newStreak = 1;
saveProgress({ ...progress, streak: newStreak, totalWins: progress.totalWins + 1 });
}
SessionStorage caches generated games so replaying the same theme skips LLM calls:
cacheGame(`${theme}-${difficulty}-${enableImageGen}`, game);
Supabase hooks are stubbed for a future global leaderboard.
Scoring Formula
export function calculateScore(stats, totalPairs): number {
const base = totalPairs * 100;
const timeBonus = Math.max(0, 300 - stats.elapsedSeconds * 2);
const movePenalty = stats.moves * 3;
const missPenalty = stats.misses * 8;
const hintPenalty = stats.hintsUsed * 15;
const accuracyBonus = Math.round((stats.matches / Math.max(stats.moves, 1)) * 50);
return Math.max(100, Math.round(
base + timeBonus + accuracyBonus - movePenalty - missPenalty - hintPenalty
));
}
Speed + accuracy rewarded; hints and misses penalized.
Deployment Notes
# .env.local
OPENROUTER_API_KEY=sk-or-v1-...
NEXT_PUBLIC_APP_URL=https://your-app.vercel.app
| Platform | Function timeout | Recommendation |
|---|---|---|
| Vercel Hobby | 10s | Too short for free models |
| Vercel Pro | 60s+ | Works with parallel batching |
| Local dev | Unlimited | Best for development |
Lessons Learned
-
Never put side effects in
setStateupdaters — server actions, fetches, and router updates belong in effects or event handlers. - Batch LLM calls — one giant JSON response is slow and truncates; parallel small batches win.
- Pool hints at generation time — gameplay stays instant and free.
- Model fallbacks are mandatory — free tiers are flaky; rank from OpenRouter's curated list.
-
API routes > Server Actions for long operations — explicit timeouts and
maxDurationcontrol.
What's Next
- Supabase global leaderboard
- SSE streaming during card generation (cards appear as they're created)
- Multiplayer rooms with shared AI-generated boards
- PWA with offline cached games
Try It
git clone <repo>
npm install && cp .env.example .env.local
npm run dev
Enter "neon samurai cyber gardens" and see what the models dream up.
Screenshots
Code & more: https://www.dailybuild.xyz/project/194-memory-match






Top comments (0)