DEV Community

Cover image for Building an AI-Native Memory Match Game with Next.js 15 and OpenRouter
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on

Building an AI-Native Memory Match Game with Next.js 15 and OpenRouter

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
Enter fullscreen mode Exit fullscreen mode

The app follows a strict separation:

  1. Client — rendering, animations, game state, LocalStorage
  2. Server — all LLM calls (API key never touches the browser)
  3. 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);
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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":"🐱",...}]}`;
Enter fullscreen mode Exit fullscreen mode

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
];
Enter fullscreen mode Exit fullscreen mode
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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Game State: A Simple State Machine

GameApp.tsx drives four phases:

type AppPhase = "landing" | "loading" | "playing" | "won";
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

CSS essentials:

.card-perspective { perspective: 1000px; }
.card-inner { transform-style: preserve-3d; }
.card-face { backface-visibility: hidden; }
.card-back { transform: rotateY(180deg); }
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

SessionStorage caches generated games so replaying the same theme skips LLM calls:

cacheGame(`${theme}-${difficulty}-${enableImageGen}`, game);
Enter fullscreen mode Exit fullscreen mode

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
  ));
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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

  1. Never put side effects in setState updaters — server actions, fetches, and router updates belong in effects or event handlers.
  2. Batch LLM calls — one giant JSON response is slow and truncates; parallel small batches win.
  3. Pool hints at generation time — gameplay stays instant and free.
  4. Model fallbacks are mandatory — free tiers are flaky; rank from OpenRouter's curated list.
  5. API routes > Server Actions for long operations — explicit timeouts and maxDuration control.

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 fullscreen mode Exit fullscreen mode

Enter "neon samurai cyber gardens" and see what the models dream up.

Screenshots

Memory Match 1

Memory Match 2

Memory Match 3

Memory Match 4

Memory Match 5

Memory Match 6

Code & more: https://www.dailybuild.xyz/project/194-memory-match

Top comments (0)