diff --git a/app/api/game-spec/[slug]/route.ts b/app/api/game-spec/[slug]/route.ts new file mode 100644 index 0000000..20c1261 --- /dev/null +++ b/app/api/game-spec/[slug]/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isValidGameSlug } from "@/lib/parse-game-input"; +import { readSpecMd } from "@/lib/game-reverse-storage"; + +export const runtime = "nodejs"; + +type RouteContext = { params: Promise<{ slug: string }> }; + +export async function GET(request: NextRequest, context: RouteContext) { + const { slug: rawSlug } = await context.params; + const slug = rawSlug.trim().toLowerCase(); + + if (!isValidGameSlug(slug)) { + return NextResponse.json({ error: "Invalid slug." }, { status: 400 }); + } + + const specMd = await readSpecMd(slug); + if (!specMd) { + return NextResponse.json( + { error: "GAME.md not found. Run game reverse first." }, + { status: 404 } + ); + } + + const download = request.nextUrl.searchParams.has("download"); + const headers: Record = { + "Content-Type": "text/markdown; charset=utf-8", + "Cache-Control": "public, max-age=86400, s-maxage=86400", + "Access-Control-Allow-Origin": "*", + }; + if (download) { + headers["Content-Disposition"] = `attachment; filename="${slug}-GAME.md"`; + } + + return new NextResponse(specMd, { + status: 200, + headers, + }); +} + +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + }); +} diff --git a/app/api/library/route.ts b/app/api/library/route.ts index 9d73e52..324f927 100644 --- a/app/api/library/route.ts +++ b/app/api/library/route.ts @@ -11,7 +11,7 @@ const LIMIT = 24; function parseKindFilter(raw: string | null): LibraryKindFilter { const v = raw?.trim().toLowerCase(); - if (v === "code" || v === "website") return v; + if (v === "code" || v === "website" || v === "game") return v; return "all"; } diff --git a/app/api/reverse-game/route.ts b/app/api/reverse-game/route.ts new file mode 100644 index 0000000..d33a08d --- /dev/null +++ b/app/api/reverse-game/route.ts @@ -0,0 +1,164 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isValidGameSlug } from "@/lib/parse-game-input"; +import { + ensureGameReversed, + type GameReverseResult, +} from "@/lib/game-reverse-engine"; +import { readGameReverse } from "@/lib/game-reverse-storage"; + +export const runtime = "nodejs"; +export const maxDuration = 300; + +const ROUTE_TIMEOUT_MS = 240_000; +const inFlight = new Map>(); + +function encodeSse(event: string, data: unknown): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +} + +async function executeGameReverse(opts: { + slug: string; + gameName: string; + stream: boolean; + force?: boolean; +}): Promise { + const { slug, gameName, stream, force } = opts; + + if (!stream) { + const existing = inFlight.get(slug); + if (existing) { + const result = await existing; + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + return NextResponse.json({ + prompt: result.prompt, + specPath: result.specPath, + fromCache: result.fromCache, + }); + } + + const promise = ensureGameReversed({ slug, gameName, force }); + inFlight.set(slug, promise); + try { + const result = await promise; + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + return NextResponse.json({ + prompt: result.prompt, + specPath: result.specPath, + fromCache: result.fromCache, + }); + } finally { + inFlight.delete(slug); + } + } + + const encoder = new TextEncoder(); + const streamBody = new ReadableStream({ + async start(controller) { + const send = (event: string, data: unknown) => { + controller.enqueue(encoder.encode(encodeSse(event, data))); + }; + + try { + const result = await ensureGameReversed({ + slug, + gameName, + force, + onStatus: (message) => send("status", { message }), + }); + + if (!result.ok) { + send("error", { error: result.error }); + controller.close(); + return; + } + + send("done", { + prompt: result.prompt, + specPath: result.specPath, + fromCache: result.fromCache, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + send("error", { error: msg }); + } finally { + controller.close(); + } + }, + }); + + return new NextResponse(streamBody, { + status: 200, + headers: { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +} + +export async function POST(request: NextRequest) { + let body: { + gameSlug?: string; + gameName?: string; + stream?: boolean; + force?: boolean; + }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const gameSlug = body.gameSlug?.trim().toLowerCase(); + if (!gameSlug || !isValidGameSlug(gameSlug)) { + return NextResponse.json( + { error: "gameSlug is required and must be a valid slug." }, + { status: 400 } + ); + } + + let gameName = body.gameName?.trim(); + if (!gameName) { + const cached = await readGameReverse(gameSlug); + if (cached?.meta.gameName) { + gameName = cached.meta.gameName; + } + } + + if (!gameName) { + return NextResponse.json( + { error: "gameName is required for the first run." }, + { status: 400 } + ); + } + + const timer = new Promise((_, reject) => + setTimeout(() => reject(new Error("__timeout__")), ROUTE_TIMEOUT_MS) + ); + + try { + return await Promise.race([ + executeGameReverse({ + slug: gameSlug, + gameName, + stream: body.stream === true, + force: body.force === true, + }), + timer, + ]); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg === "__timeout__") { + return NextResponse.json( + { error: "Game reverse timed out. Try again." }, + { status: 504 } + ); + } + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/app/game/[slug]/page.tsx b/app/game/[slug]/page.tsx new file mode 100644 index 0000000..9d8d0bd --- /dev/null +++ b/app/game/[slug]/page.tsx @@ -0,0 +1,36 @@ +import { notFound } from "next/navigation"; +import { GameReversePage } from "@/components/game-reverse-page"; +import { + isValidGameSlug, + nameToSlug, + parseGameInput, +} from "@/lib/parse-game-input"; + +type PageProps = { + params: Promise<{ slug: string }>; + searchParams: Promise<{ name?: string }>; +}; + +export default async function GameSlugPage({ + params, + searchParams, +}: PageProps) { + const { slug: rawSlug } = await params; + const slug = rawSlug.trim().toLowerCase(); + const { name: rawName } = await searchParams; + + if (!isValidGameSlug(slug)) { + notFound(); + } + + const parsed = rawName ? parseGameInput(rawName) : null; + if (!parsed) { + notFound(); + } + + if (nameToSlug(parsed.name) !== slug) { + notFound(); + } + + return ; +} diff --git a/app/game/page.tsx b/app/game/page.tsx new file mode 100644 index 0000000..a11565b --- /dev/null +++ b/app/game/page.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from "next"; +import { GameReverseHome } from "@/components/game-reverse-home"; +import { JsonLd } from "@/components/json-ld"; + +export const metadata: Metadata = { + title: "Game Reverse — GitReverse", + description: + "Reverse engineer any game into a GAME.md spec and a Cursor-ready build prompt.", + alternates: { canonical: "https://gitreverse.com/game" }, + openGraph: { + title: "Game Reverse — GitReverse", + description: + "Reverse engineer any game into a GAME.md spec and a Cursor-ready build prompt.", + url: "https://gitreverse.com/game", + }, + twitter: { + title: "Game Reverse — GitReverse", + description: + "Reverse engineer any game into a GAME.md spec and a Cursor-ready build prompt.", + }, +}; + +const gameJsonLd = { + "@context": "https://schema.org", + "@type": "WebPage", + name: "Game Reverse — GitReverse", + url: "https://gitreverse.com/game", + description: + "Reverse engineer any game into a GAME.md spec and a Cursor-ready build prompt.", + isPartOf: { + "@type": "WebSite", + name: "GitReverse", + url: "https://gitreverse.com", + }, +}; + +export default function GamePage() { + return ( + <> + + + + ); +} diff --git a/app/sitemap.ts b/app/sitemap.ts index d62633b..548546b 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -17,6 +17,12 @@ export default async function sitemap(): Promise { changeFrequency: "daily", priority: 0.9, }, + { + url: `${BASE_URL}/game`, + lastModified: new Date(), + changeFrequency: "weekly", + priority: 0.9, + }, ]; let dynamicRoutes: MetadataRoute.Sitemap = []; diff --git a/app/specs/[slug]/page.tsx b/app/specs/[slug]/page.tsx new file mode 100644 index 0000000..484816a --- /dev/null +++ b/app/specs/[slug]/page.tsx @@ -0,0 +1,64 @@ +import { notFound } from "next/navigation"; +import { Navbar } from "@/components/navbar"; +import { PromptMarkdown } from "@/components/prompt-markdown"; +import { isValidGameSlug } from "@/lib/parse-game-input"; +import { readGameReverse } from "@/lib/game-reverse-storage"; + +type PageProps = { + params: Promise<{ slug: string }>; +}; + +export default async function GameSpecPage({ params }: PageProps) { + const { slug: rawSlug } = await params; + const slug = rawSlug.trim().toLowerCase(); + + if (!isValidGameSlug(slug)) { + notFound(); + } + + const cached = await readGameReverse(slug); + if (!cached) { + notFound(); + } + + const downloadHref = `/api/game-spec/${encodeURIComponent(slug)}?download=1`; + + return ( +
+ + +
+

{`${cached.meta.gameName} game spec`}

+ +
+
+
+
+
+

+ Game spec +

+

+ {cached.meta.gameName} +

+
+ +
+ {cached.specMd} +
+
+
+
+
+ ); +} diff --git a/components/game-reverse-home.tsx b/components/game-reverse-home.tsx new file mode 100644 index 0000000..c9a3497 --- /dev/null +++ b/components/game-reverse-home.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Navbar } from "@/components/navbar"; +import { HOME_GAME_EXAMPLES } from "@/lib/home-example-repos"; +import { nameToSlug, parseGameInput } from "@/lib/parse-game-input"; + +export function GameReverseHome() { + const router = useRouter(); + const [gameName, setGameName] = useState(""); + const [error, setError] = useState(null); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + const gameRaw = gameName.trim(); + const isUrl = + /^https?:\/\//i.test(gameRaw) || + /^github\.com\//i.test(gameRaw) || + (/^[^/\s]+\/[^/\s]+$/.test(gameRaw) && !gameRaw.includes(" ")); + if (isUrl) { + setError( + "That looks like a URL or repo. Use Codebase or Website reverse on the home page." + ); + return; + } + + const parsed = parseGameInput(gameRaw); + if (!parsed) { + setError("Could not parse game name. Enter a title like GTA Vice City."); + return; + } + + const slug = nameToSlug(parsed.name); + void router.push( + `/game/${encodeURIComponent(slug)}?name=${encodeURIComponent(parsed.name)}` + ); + } + + return ( +
+ + +
+
+
+

+ Reverse a game +
+ into a prompt +

+

+ Type any game title and get a GAME.md spec plus a Cursor-ready + build prompt. +

+
+ +
+
+
+
+
+
+
+ setGameName(e.target.value)} + required + /> +
+
+
+ +
+
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+
+ + Try example games: + + {HOME_GAME_EXAMPLES.map((example) => ( +
+
+ +
+ ))} +
+
+ +
+ +

+ Also reverse{" "} + + codebases + {" "} + or{" "} + + websites + {" "} + on the home page. +

+
+
+
+ + +
+ ); +} diff --git a/components/game-reverse-page.tsx b/components/game-reverse-page.tsx new file mode 100644 index 0000000..3e4f812 --- /dev/null +++ b/components/game-reverse-page.tsx @@ -0,0 +1,318 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { GameSpecFlavorText } from "@/components/game-spec-flavor-text"; +import { Navbar } from "@/components/navbar"; +import { PromptMarkdown } from "@/components/prompt-markdown"; +import { nameToSlug, parseGameInput } from "@/lib/parse-game-input"; + +type GameReversePageProps = { + gameSlug: string; + gameName: string; +}; + +export function GameReversePage({ gameSlug, gameName }: GameReversePageProps) { + const router = useRouter(); + + const [currentSlug, setCurrentSlug] = useState(gameSlug); + const [currentGameName, setCurrentGameName] = useState(gameName); + const [inputValue, setInputValue] = useState(gameName); + + const [prompt, setPrompt] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [statusLine, setStatusLine] = useState("Checking if it's cached…"); + const [copied, setCopied] = useState(false); + const started = useRef(false); + const resultsRef = useRef(null); + + const run = useCallback(async (slug: string, name: string) => { + setLoading(true); + setError(null); + setPrompt(null); + setStatusLine("Checking if it's cached…"); + + try { + const res = await fetch("/api/reverse-game", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + gameSlug: slug, + gameName: name, + stream: true, + }), + }); + + const contentType = res.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + const data = (await res.json()) as { + prompt?: string; + fromCache?: boolean; + error?: string; + }; + if (!res.ok || data.error) { + throw new Error(data.error ?? `Request failed (${res.status})`); + } + if (data.prompt) { + setPrompt(data.prompt); + if (data.fromCache) setStatusLine("Loaded from cache"); + } else { + throw new Error("No prompt returned."); + } + return; + } + + if (!res.ok || !res.body) { + throw new Error(`Request failed (${res.status})`); + } + + const reader = res.body.getReader(); + const dec = new TextDecoder(); + let buf = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + + for (;;) { + const idx = buf.indexOf("\n\n"); + if (idx < 0) break; + const block = buf.slice(0, idx); + buf = buf.slice(idx + 2); + + const eventLine = block.split("\n").find((l) => l.startsWith("event: ")); + const dataLine = block.split("\n").find((l) => l.startsWith("data: ")); + if (!eventLine || !dataLine) continue; + + const event = eventLine.slice(7).trim(); + try { + const json = JSON.parse(dataLine.slice(5).trim()) as { + message?: string; + prompt?: string; + fromCache?: boolean; + error?: string; + }; + + if (event === "status" && typeof json.message === "string") { + setStatusLine(json.message); + } + if (event === "done" && typeof json.prompt === "string") { + setPrompt(json.prompt); + if (json.fromCache) setStatusLine("Loaded from cache"); + } + if (event === "error" && typeof json.error === "string") { + throw new Error(json.error); + } + } catch (e) { + if (e instanceof Error && e.message !== "Unexpected end of JSON input") { + throw e; + } + } + } + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (started.current) return; + started.current = true; + void run(gameSlug, gameName); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (prompt && resultsRef.current) { + resultsRef.current.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }, [prompt]); + + const copyPrompt = async () => { + if (!prompt) return; + try { + await navigator.clipboard.writeText(prompt); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch { + // ignore + } + }; + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + if (loading) return; + + const raw = inputValue.trim(); + const isUrl = + /^https?:\/\//i.test(raw) || + /^github\.com\//i.test(raw) || + (/^[^/\s]+\/[^/\s]+$/.test(raw) && !raw.includes(" ")); + if (isUrl) { + setError( + "That looks like a URL or repo. Use Codebase or Website mode instead." + ); + return; + } + + const parsed = parseGameInput(raw); + if (!parsed) { + setError("Could not parse game name. Enter a title like GTA Vice City."); + return; + } + + const slug = nameToSlug(parsed.name); + setCurrentSlug(slug); + setCurrentGameName(parsed.name); + router.replace( + `/game/${encodeURIComponent(slug)}?name=${encodeURIComponent(parsed.name)}`, + { scroll: false } + ); + void run(slug, parsed.name); + } + + const isWritingSpec = statusLine === "Writing GAME.md"; + + return ( +
+ + +
+

{`${currentGameName} — reverse-engineered prompt`}

+ +
+
+
+
+
+
+
+ setInputValue(e.target.value)} + required + /> +
+
+
+ +
+
+ + {loading && !prompt && !error ? ( +
+ {isWritingSpec ? ( + + ) : ( +

+ {statusLine}… +

+ )} +
+ ) : null} + + {error ? ( +
+ {error} + +
+ ) : null} + +
+
+ + {prompt ? ( +
+
+
+
+

+ Reverse engineered prompt +

+
+
+
+ +
+
+
+
+ {prompt} +
+
+
+ ) : null} +
+
+ ); +} diff --git a/components/game-spec-flavor-text.tsx b/components/game-spec-flavor-text.tsx new file mode 100644 index 0000000..daa1085 --- /dev/null +++ b/components/game-spec-flavor-text.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const ELLIPSIS_MS = 450; +const FLAVOR_MS = 2400; + +/** Rotating filler copy shown while the GAME.md LLM call is in flight. */ +const GAME_SPEC_FLAVOR_LINES = [ + "mapping the core loop", + "picking the right stack", + "scoping the vertical slice", + "defining controls and camera", + "sketching the world palette", + "writing architecture rules", + "listing do and don't guardrails", + "planning implementation order", + "tuning arcade feel defaults", + "drafting procedural world tiers", + "checking genre fit", + "polishing GAME.md", + "almost done with the game spec", +] as const; + +const ELLIPSIS_FRAMES = ["", ".", "..", "..."] as const; + +export function GameSpecFlavorText() { + const [flavorIndex, setFlavorIndex] = useState(0); + const [ellipsisIndex, setEllipsisIndex] = useState(0); + + useEffect(() => { + const ellipsisId = window.setInterval(() => { + setEllipsisIndex((i) => (i + 1) % ELLIPSIS_FRAMES.length); + }, ELLIPSIS_MS); + + const flavorId = window.setInterval(() => { + setFlavorIndex((i) => (i + 1) % GAME_SPEC_FLAVOR_LINES.length); + }, FLAVOR_MS); + + return () => { + window.clearInterval(ellipsisId); + window.clearInterval(flavorId); + }; + }, []); + + const line = GAME_SPEC_FLAVOR_LINES[flavorIndex] ?? GAME_SPEC_FLAVOR_LINES[0]; + const dots = ELLIPSIS_FRAMES[ellipsisIndex] ?? ""; + + return ( +

+ {line} + + {dots} + +

+ ); +} diff --git a/components/library-page.tsx b/components/library-page.tsx index d908b91..96fb545 100644 --- a/components/library-page.tsx +++ b/components/library-page.tsx @@ -380,6 +380,37 @@ function LibraryCard({ entry }: { entry: LibraryEntry }) { } function KindBadge({ kind }: { kind: LibraryEntry["kind"] }) { + if (kind === "game") { + return ( + + + + + + Game + + ); + } + if (kind === "website") { return ( (null); const autoSubmitStartedRef = useRef(false); - /** Home: honor `?mode=website` for shareable website-reverse entry. */ + /** Home: honor `?mode=website`; legacy `?mode=game` redirects to /game. */ useEffect(() => { if (!isHome || typeof window === "undefined") return; const params = new URLSearchParams(window.location.search); - if (params.get("mode")?.trim().toLowerCase() === "website") { + const mode = params.get("mode")?.trim().toLowerCase(); + if (mode === "game") { + void router.replace("/game"); + return; + } + if (mode === "website") { setHomeMode("website"); } - }, [isHome]); + }, [isHome, router]); const runReversePrompt = useCallback(async (input: string) => { setError(null); @@ -308,7 +313,13 @@ export function ReversePromptHome({ a prompt

- Reverse engineer any codebase or website into a prompt. + Reverse engineer any codebase or website into a prompt.{" "} + + Games too. +

) : owner && repo ? ( @@ -341,7 +352,7 @@ export function ReversePromptHome({ aria-selected={homeMode === "website"} aria-pressed={homeMode === "website"} onClick={() => setHomeModeAndUrl("website")} - className={`px-5 py-2.5 text-sm font-bold transition-colors ${ + className={`border-r-[2.5px] border-zinc-900 px-5 py-2.5 text-sm font-bold transition-colors ${ homeMode === "website" ? "bg-[#d31611] text-white" : "bg-transparent text-zinc-600 hover:bg-zinc-50" @@ -349,6 +360,12 @@ export function ReversePromptHome({ > Website + + Game + ) : null} diff --git a/lib/assets/GAME.template.md b/lib/assets/GAME.template.md new file mode 100644 index 0000000..132a7c0 --- /dev/null +++ b/lib/assets/GAME.template.md @@ -0,0 +1,156 @@ +# Game Spec: [Game Title] + +## 1. Identity & Feel + +Describe the game's mood, era, camera, and what one play session feels like. + +- Title: +- Genre / subgenre: +- Era / setting: +- Core fantasy (what the player imagines they are): +- Session length target: +- Reference feel (not a clone disclaimer): + +### Key Characteristics + +- [Characteristic] +- [Characteristic] +- [Characteristic] + +## 2. Vertical Slice (Honest Scope) + +Define a **buildable** slice, not the full commercial game. + +- What ships in v1: +- What is explicitly out of scope: +- Win / lose / fail states: +- Single player only unless evidence says otherwise: + +## 3. Frozen Stack + +Pick the smallest stack that fits the game. **Freeze it** so agents do not swap engines mid build. + +| Layer | Choice | Notes | +| --- | --- | --- | +| Runtime | [Browser / Canvas 2D / Three.js / R3F] | | +| Language | TypeScript | | +| Bundler | Vite | | +| Physics | [None / custom arcade / Rapier only if required] | | +| Audio | Web Audio API | | +| State | module store or minimal zustand for HUD only | | + +### Stack rules + +- 2D platformers: Canvas 2D or lightweight Phaser, not Three.js. +- 3D open world / driving: Vite + TypeScript + **vanilla Three.js** (no React Three Fiber unless UI heavy). +- Do **not** add Rapier, Cannon, or Unity unless the game is literally a physics toy. +- No backend for v1. Static deploy. + +## 4. Architecture + +Simulation core must never import the renderer. + +``` +src/core/ math, input intent, fixed timestep loop (no three) +src/world/ procedural world / level data (pure where possible) +src/systems/ collision, AI, camera helpers +src/render/ three.js or canvas only here +src/ui/ DOM HUD overlay +src/main.ts orchestrator +``` + +- Fixed timestep: 60 Hz simulation, render once per frame. +- No allocations in hot paths (reuse vectors, pool particles). +- One input abstraction: keyboard + touch feed the same intent struct. + +## 5. Mechanics & Controls + +| Verb | Input | Behavior | +| --- | --- | --- | +| [Move] | | | +| [Jump / accelerate] | | | +| [Interact] | | | + +### Camera + +- [Third person chase / top down / side scroll] +- Lag, look ahead, FOV vs speed if 3D + +### Fail states + +- [Death / wasted / game over flow] + +## 6. World & Art Direction + +### Palette + +| Role | Color | Usage | +| --- | --- | --- | +| Sky / background | | | +| Ground / road | | | +| Accent | | | +| UI | | | + +### Lighting & atmosphere + +- Time of day: +- Fog / bloom / post FX level: +- Mood keywords: + +### Asset tiers (critical) + +| Tier | Use for | How | +| --- | --- | --- | +| Procedural | terrain, buildings, roads, VFX | code geometry, canvas textures | +| Primitives | cars, characters, props at v1 | composed meshes, not GLB per object | +| Generated GLB | optional hero mesh later | one character or vehicle max | + +**Do not** generate a mesh per building. **Do not** block shipping on Meshy/Tripo. + +## 7. Audio & HUD + +### Audio + +- Music: [style, loop, when it plays] +- SFX: [engine, impacts, UI — Web Audio synthesis preferred for v1] + +### HUD + +- Speed / health / score / minimap as needed +- Touch controls on coarse pointers +- Start screen + game over screen + +## 8. Implementation Order + +Build working code at each step before adding features. + +1. [Scaffold + blank scene] +2. [Core loop + input] +3. [World / level] +4. [Player / vehicle feel] +5. [Camera] +6. [Collision + damage] +7. [NPCs / traffic / enemies if any] +8. [HUD + SFX] +9. [Mobile quality presets] + +## 9. Do / Don't + +### Do + +- Ship a playable vertical slice first +- Tune **feel** (acceleration, camera, impact) before adding content +- Keep `core/` free of renderer imports +- Use seeded procedural generation for replayable worlds + +### Don't + +- Rebuild the entire commercial map +- One GLB per building or tree +- 2000 line monolithic main file +- Add wanted system / multiplayer before driving or movement feels good +- Invent mechanics with no evidence from the game name / genre + +## Evidence Notes + +Document what came from external evidence vs model knowledge. Leave blank in v1 name only mode. diff --git a/lib/file-tree-formatter.ts b/lib/file-tree-formatter.ts index 91c4f69..f5dab77 100644 --- a/lib/file-tree-formatter.ts +++ b/lib/file-tree-formatter.ts @@ -66,10 +66,6 @@ export const treeToString = (node: TreeNode, prefix = "", isRoot = true): string sortTreeNodes(node); let result = ""; - if (!isRoot) { - result += `${prefix}${node.name}${node.isDirectory ? "/" : ""}\n`; - } - for (let i = 0; i < node.children.length; i++) { const child = node.children[i]; if (!child) continue; diff --git a/lib/game-evidence.ts b/lib/game-evidence.ts new file mode 100644 index 0000000..941f526 --- /dev/null +++ b/lib/game-evidence.ts @@ -0,0 +1,33 @@ +export type GameEvidenceSource = "name-only"; + +export type GameEvidence = { + source: GameEvidenceSource; + name: string; + slug: string; + /** Reserved for Steam, RAWG, Wikipedia, screenshots, etc. */ + metadata?: Record | null; +}; + +export function evidenceStatusMessage(source: GameEvidenceSource): string { + switch (source) { + case "name-only": + return "Recalling game from model knowledge"; + default: + return "Gathering game evidence"; + } +} + +/** + * V1: model knowledge only. Future: Wikipedia, RAWG, Steam store page, screenshots. + */ +export async function gatherGameEvidence(opts: { + name: string; + slug: string; +}): Promise { + return { + source: "name-only", + name: opts.name, + slug: opts.slug, + metadata: null, + }; +} diff --git a/lib/game-prompt-utils.ts b/lib/game-prompt-utils.ts new file mode 100644 index 0000000..6470cd3 --- /dev/null +++ b/lib/game-prompt-utils.ts @@ -0,0 +1,16 @@ +import { gameSpecPageUrl } from "@/lib/site-url"; + +const GAME_SPEC_SUFFIX_RE = + /\n*Use this game spec:\s*(?:\[[^\]]*\]\([^)]+\)|https?:\/\/\S+)\s*$/i; + +export function stripGameSpecLink(prompt: string): string { + return prompt.replace(GAME_SPEC_SUFFIX_RE, "").trimEnd(); +} + +export function appendGameSpecLink(prompt: string, slug: string): string { + const stripped = stripGameSpecLink(prompt); + const link = gameSpecPageUrl(slug); + const suffix = `Use this game spec: ${link}`; + if (stripped.includes(suffix)) return stripped; + return `${stripped}\n\n${suffix}`; +} diff --git a/lib/game-reverse-engine.ts b/lib/game-reverse-engine.ts new file mode 100644 index 0000000..4b66fab --- /dev/null +++ b/lib/game-reverse-engine.ts @@ -0,0 +1,211 @@ +import { callQuickLlm, resolveLlmTarget } from "@/lib/quick-llm"; +import { appendGameSpecLink } from "@/lib/game-prompt-utils"; +import { buildGameSpecSystemPrompt } from "@/lib/game-spec-system-prompt"; +import { GAME_REVERSE_SYSTEM_PROMPT } from "@/lib/game-reverse-system-prompt"; +import { + evidenceStatusMessage, + gatherGameEvidence, + type GameEvidence, +} from "@/lib/game-evidence"; +import { + readGameReverse, + specApiPath, + writeGameReverse, +} from "@/lib/game-reverse-storage"; + +export type GameReverseResult = + | { + ok: true; + prompt: string; + specMd: string; + specPath: string; + fromCache: boolean; + } + | { ok: false; error: string; status: number }; + +function buildSpecUserMessage(opts: { + gameName: string; + evidence: GameEvidence; +}): string { + const lines: string[] = [ + `# Target game`, + ``, + `Title: ${opts.gameName}`, + `Slug: ${opts.evidence.slug}`, + ``, + `Evidence source: ${opts.evidence.source}`, + ``, + ]; + + if (opts.evidence.metadata && Object.keys(opts.evidence.metadata).length > 0) { + lines.push( + `## External metadata JSON`, + ``, + "```json", + JSON.stringify(opts.evidence.metadata, null, 2), + "```", + `` + ); + } + + lines.push( + `## Notes`, + ``, + opts.evidence.source === "name-only" + ? "No external evidence. Use well known facts about this game and scope a honest browser vertical slice." + : "Prefer external metadata when present." + ); + + return lines.join("\n"); +} + +function buildReversePromptUserMessage(opts: { + gameName: string; + evidence: GameEvidence; + specMd: string; +}): string { + const specSummary = + opts.specMd.length > 2500 + ? `${opts.specMd.slice(0, 2500)}\n\n… (GAME.md truncated)` + : opts.specMd; + + return [ + `# Target game`, + ``, + `Title: ${opts.gameName}`, + `Slug: ${opts.evidence.slug}`, + ``, + `Evidence source: ${opts.evidence.source}`, + ``, + opts.evidence.metadata + ? `## Metadata JSON\n\n\`\`\`json\n${JSON.stringify(opts.evidence.metadata, null, 2)}\n\`\`\`\n` + : "", + `## GAME.md summary`, + ``, + specSummary, + ] + .filter((line, i, arr) => !(line === "" && arr[i - 1] === "")) + .join("\n"); +} + +export async function ensureGameReversed(opts: { + slug: string; + gameName: string; + onStatus?: (message: string) => void; + force?: boolean; +}): Promise { + const { slug, gameName, onStatus, force } = opts; + + if (!force) { + const cached = await readGameReverse(slug); + if (cached) { + const prompt = appendGameSpecLink(cached.meta.prompt, slug); + if (prompt !== cached.meta.prompt) { + void writeGameReverse({ + slug, + gameName: cached.meta.gameName, + specMd: cached.specMd, + prompt, + }).catch((e) => { + console.warn( + `[reverse-game] failed to heal spec link for ${slug}:`, + e instanceof Error ? e.message : e + ); + }); + } + return { + ok: true, + prompt, + specMd: cached.specMd, + specPath: specApiPath(slug), + fromCache: true, + }; + } + } + + const requestStartedAt = Date.now(); + const llm = resolveLlmTarget(); + if ("error" in llm) { + return { ok: false, error: llm.error, status: 500 }; + } + + onStatus?.("Recalling game"); + let evidence: GameEvidence; + const evidenceStartedAt = Date.now(); + try { + evidence = await gatherGameEvidence({ name: gameName, slug }); + onStatus?.(evidenceStatusMessage(evidence.source)); + console.log( + `[reverse-game] evidence source=${evidence.source} elapsed=${Date.now() - evidenceStartedAt}ms` + ); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.log( + `[reverse-game] evidence FAILED after ${Date.now() - evidenceStartedAt}ms: ${msg}` + ); + return { ok: false, error: msg, status: 502 }; + } + + onStatus?.("Understanding mechanics"); + onStatus?.("Writing GAME.md"); + const specStartedAt = Date.now(); + const specResult = await callQuickLlm( + llm, + buildGameSpecSystemPrompt(), + buildSpecUserMessage({ gameName, evidence }), + 12_000 + ); + console.log( + `[reverse-game] GAME.md elapsed=${Date.now() - specStartedAt}ms` + ); + if (!specResult.ok) { + return { ok: false, error: specResult.error, status: specResult.status }; + } + + onStatus?.("Reverse engineering prompt"); + const promptStartedAt = Date.now(); + const promptResult = await callQuickLlm( + llm, + GAME_REVERSE_SYSTEM_PROMPT, + buildReversePromptUserMessage({ + gameName, + evidence, + specMd: specResult.text, + }), + 4096 + ); + console.log( + `[reverse-game] prompt elapsed=${Date.now() - promptStartedAt}ms` + ); + if (!promptResult.ok) { + return { ok: false, error: promptResult.error, status: promptResult.status }; + } + + const finalPrompt = appendGameSpecLink(promptResult.text, slug); + console.log(`[reverse-game] TOTAL elapsed=${Date.now() - requestStartedAt}ms`); + + try { + await writeGameReverse({ + slug, + gameName, + specMd: specResult.text, + prompt: finalPrompt, + metadata: evidence.metadata ?? null, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return { + ok: false, + error: `Failed to save cache: ${msg}`, + status: 500, + }; + } + + return { + ok: true, + prompt: finalPrompt, + specMd: specResult.text, + specPath: specApiPath(slug), + fromCache: false, + }; +} diff --git a/lib/game-reverse-storage.ts b/lib/game-reverse-storage.ts new file mode 100644 index 0000000..6ee4d53 --- /dev/null +++ b/lib/game-reverse-storage.ts @@ -0,0 +1,82 @@ +import { getSupabase } from "@/lib/supabase"; + +export type GameReverseMeta = { + gameName: string; + prompt: string; + updatedAt: string; +}; + +export async function readGameReverse( + slug: string +): Promise<{ specMd: string; meta: GameReverseMeta } | null> { + const supabase = getSupabase(); + if (!supabase) return null; + + const { data, error } = await supabase + .from("game_reverse_cache") + .select("game_name, prompt, spec_md, cached_at") + .eq("slug", slug) + .maybeSingle(); + + if (error || !data?.prompt || !data?.spec_md || !data?.game_name) { + return null; + } + + return { + specMd: data.spec_md as string, + meta: { + gameName: data.game_name as string, + prompt: data.prompt as string, + updatedAt: data.cached_at as string, + }, + }; +} + +export async function readSpecMd(slug: string): Promise { + const supabase = getSupabase(); + if (!supabase) return null; + + const { data, error } = await supabase + .from("game_reverse_cache") + .select("spec_md") + .eq("slug", slug) + .maybeSingle(); + + if (error || !data?.spec_md) return null; + return data.spec_md as string; +} + +export async function writeGameReverse(opts: { + slug: string; + gameName: string; + specMd: string; + prompt: string; + metadata?: Record | null; +}): Promise { + const supabase = getSupabase(); + if (!supabase) { + throw new Error( + "Supabase not configured. Set SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY." + ); + } + + const { error } = await supabase.from("game_reverse_cache").upsert( + { + slug: opts.slug, + game_name: opts.gameName, + spec_md: opts.specMd, + prompt: opts.prompt, + metadata: opts.metadata ?? null, + cached_at: new Date().toISOString(), + }, + { onConflict: "slug" } + ); + + if (error) { + throw new Error(error.message); + } +} + +export function specApiPath(slug: string): string { + return `/api/game-spec/${encodeURIComponent(slug)}`; +} diff --git a/lib/game-reverse-system-prompt.ts b/lib/game-reverse-system-prompt.ts new file mode 100644 index 0000000..cfc7d0c --- /dev/null +++ b/lib/game-reverse-system-prompt.ts @@ -0,0 +1,26 @@ +export const GAME_REVERSE_SYSTEM_PROMPT = `You are an expert at inferring how people actually prompt modern coding agents to build games. + +## Task + +You are given a **game title**, optional evidence, and a short **GAME.md spec summary**. Output **one synthetic user message**: the kind of prompt a **non-technical or lightly technical** person might paste into Cursor, Claude Code, Codex, or ChatGPT code mode to rebuild a **playable vertical slice** of this game in one "vibe coding" pass. + +## What the output must be + +- **Plain language.** Sounds like a real request ("Build me…", "I want…"), not an architecture doc. +- **Outcome focused.** Describe what the game should *feel* like to play, not every system. +- **Honest scope.** A browser demo slice, not the full AAA game. One city district, one level, one mechanic loop. +- **Genre appropriate.** Driving games mention feel of the car and camera. Platformers mention jump and level flow. Puzzle games mention the core loop. +- **Length:** about **120 to 200 words**, usually one short paragraph or a few tight sentences. Not a bullet list of file paths or package names. +- **Tone:** natural and conversational. Use contractions when they fit. No preamble ("Sure, here is…"), no meta ("As an AI…"). NEVER use hyphens or dashes; use commas or shorter sentences instead. + +## What to avoid + +- Dumping the full tech stack, folder layout, or GAME.md contents. +- Writing agent *system* instructions or markdown specs. +- Claiming multiplayer, full open worlds, or licensed assets. +- Inventing obscure mechanics you are not confident about. + +## Output format + +Reply with **only** the synthetic user message. No title, no quotes around it, no explanation before or after. +`; diff --git a/lib/game-spec-system-prompt.ts b/lib/game-spec-system-prompt.ts new file mode 100644 index 0000000..88476cd --- /dev/null +++ b/lib/game-spec-system-prompt.ts @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +function loadGameTemplate(): string { + const templatePath = path.join( + process.cwd(), + "lib", + "assets", + "GAME.template.md" + ); + return readFileSync(templatePath, "utf8"); +} + +export function buildGameSpecSystemPrompt(): string { + const template = loadGameTemplate(); + return `You are an expert game designer and technical lead for browser based vibe coded games. You write reusable GAME.md spec documents that coding agents (Cursor, Claude Code, Codex) can follow to build a **vertical slice**, not a AAA clone. + +## Task + +Given a game title and any available evidence, write a complete GAME.md specification. + +## Rules + +- Follow the section structure in the template below exactly (all 9 required sections). +- Scope honestly: one playable vertical slice, not the full commercial game. +- Pick stack based on genre: 2D games use Canvas 2D; 3D driving/open world defaults to Vite + TypeScript + vanilla Three.js with procedural cities and arcade vehicle physics. +- Architecture must separate simulation core (no renderer imports) from render layer. +- Asset tier rules are mandatory: procedural world, primitive actors, optional one hero GLB later. +- If evidence is thin (name only), use well known facts about the game and label uncertain items in Evidence Notes. +- When external metadata JSON is provided, prefer it over guesses. +- Output markdown only. No preamble, no code fences wrapping the whole document. + +## Template structure + +${template}`; +} diff --git a/lib/home-example-repos.ts b/lib/home-example-repos.ts index 71d682e..ec372cf 100644 --- a/lib/home-example-repos.ts +++ b/lib/home-example-repos.ts @@ -18,6 +18,13 @@ export const HOME_WEBSITE_EXAMPLES = [ { label: "Discord", url: "https://discord.com" }, ] as const; +/** Hero “Try example games” for home Game mode. */ +export const HOME_GAME_EXAMPLES = [ + { label: "GTA Vice City", name: "GTA Vice City" }, + { label: "Celeste", name: "Celeste" }, + { label: "Worms Armageddon", name: "Worms Armageddon" }, +] as const; + const EXAMPLE_OWNER_REPO_KEYS = new Set( HOME_EXAMPLES.map((ex) => { const p = parseGitHubRepoInput(ex.url); diff --git a/lib/library-query.ts b/lib/library-query.ts index 3034fe4..30ee2e3 100644 --- a/lib/library-query.ts +++ b/lib/library-query.ts @@ -2,6 +2,7 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { embedText } from "@/lib/embeddings"; import { codeEntryFromRow, + gameEntryFromRow, paginateLibraryEntries, sortLibraryEntries, websiteEntryFromRow, @@ -17,8 +18,10 @@ const MAX_FETCH_LIMIT = 96; const CODE_TABLE = "library_code_entries"; const WEBSITE_TABLE = "library_website_entries"; +const GAME_TABLE = "library_game_entries"; const CODE_COLUMNS = "id, owner, repo, prompt, cached_at, views, title"; const WEBSITE_COLUMNS = "slug, target_url, prompt, cached_at"; +const GAME_COLUMNS = "slug, game_name, prompt, cached_at"; type PromptRow = { id: number; @@ -38,6 +41,13 @@ type WebsiteRow = { cached_at: string; }; +type GameRow = { + slug: string; + game_name: string; + prompt: string; + cached_at: string; +}; + function searchWords(raw: string): string[] { return raw .trim() @@ -104,6 +114,14 @@ function applyWebsiteSort( } } +function applyGameSort( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + query: any, + sort: SortOption +) { + return applyWebsiteSort(query, sort); +} + async function fetchCodeBrowse( supabase: SupabaseClient, sort: SortOption, @@ -133,6 +151,20 @@ async function fetchWebsiteBrowse( return { rows: (data ?? []) as WebsiteRow[], total: count ?? 0 }; } +async function fetchGameBrowse( + supabase: SupabaseClient, + sort: SortOption, + fetchLimit: number +): Promise<{ rows: GameRow[]; total: number }> { + const { data, count, error } = await applyGameSort( + supabase.from(GAME_TABLE).select(GAME_COLUMNS, { count: "exact" }), + sort + ).range(0, fetchLimit - 1); + + if (error) throw new Error(error.message); + return { rows: (data ?? []) as GameRow[], total: count ?? 0 }; +} + type FtsStrategy = "fts-plain" | "fts-or" | "ilike-and" | "ilike-or"; async function fetchCodeSearch( @@ -241,6 +273,30 @@ async function fetchWebsiteSearch( return { rows: (data ?? []) as WebsiteRow[], total: count ?? 0 }; } +async function fetchGameSearch( + supabase: SupabaseClient, + search: string, + sort: SortOption, + fetchLimit: number +): Promise<{ rows: GameRow[]; total: number }> { + const words = searchWords(search); + let query = supabase.from(GAME_TABLE).select(GAME_COLUMNS, { count: "exact" }); + + if (words.length > 0) { + for (const word of words) { + query = query.or(`slug.ilike.%${word}%,game_name.ilike.%${word}%`); + } + } + + const { data, count, error } = await applyGameSort(query, sort).range( + 0, + fetchLimit - 1 + ); + + if (error) throw new Error(error.message); + return { rows: (data ?? []) as GameRow[], total: count ?? 0 }; +} + function scoreWebsiteSearch(row: WebsiteRow, search: string): number { const words = searchWords(search); if (words.length === 0) return 0; @@ -254,6 +310,19 @@ function scoreWebsiteSearch(row: WebsiteRow, search: string): number { return score / words.length; } +function scoreGameSearch(row: GameRow, search: string): number { + const words = searchWords(search); + if (words.length === 0) return 0; + let score = 0; + for (const word of words) { + const w = word.toLowerCase(); + if (row.slug.toLowerCase().includes(w)) score += 3; + if (row.game_name.toLowerCase().includes(w)) score += 3; + if (row.prompt.toLowerCase().includes(w)) score += 1; + } + return score / words.length; +} + async function fetchCodeHybrid( supabase: SupabaseClient, search: string, @@ -329,11 +398,13 @@ async function hybridSearchCount( function mergeBrowse( codeRows: PromptRow[], websiteRows: WebsiteRow[], + gameRows: GameRow[], sort: SortOption ): LibraryEntry[] { const entries = [ ...codeRows.map(codeEntryFromRow), ...websiteRows.map(websiteEntryFromRow), + ...gameRows.map(gameEntryFromRow), ]; return sortLibraryEntries(entries, sort); } @@ -341,6 +412,7 @@ function mergeBrowse( function mergeSearch( codeRows: PromptRow[], websiteRows: WebsiteRow[], + gameRows: GameRow[], search: string ): LibraryEntry[] { const entries: LibraryEntry[] = [ @@ -354,6 +426,15 @@ function mergeSearch( relevance_score: Math.max(hybridScore, textScore > 0 ? textScore / 3 : 0), }; }), + ...gameRows.map((row) => { + const entry = gameEntryFromRow(row); + const textScore = scoreGameSearch(row, search); + const hybridScore = entry.relevance_score ?? 0; + return { + ...entry, + relevance_score: Math.max(hybridScore, textScore > 0 ? textScore / 3 : 0), + }; + }), ]; entries.sort((a, b) => { @@ -383,7 +464,7 @@ function logSourceFailure( } async function safeBrowseSource( - source: "code" | "website", + source: "code" | "website" | "game", fetch: () => Promise<{ rows: T[]; total: number }> ): Promise<{ rows: T[]; total: number }> { try { @@ -395,7 +476,7 @@ async function safeBrowseSource( } async function safeSearchSource( - source: "code" | "website", + source: "code" | "website" | "game", fetch: () => Promise<{ rows: T[]; total: number }> ): Promise<{ rows: T[]; total: number }> { try { @@ -451,7 +532,7 @@ export async function browseLibrary(opts: { if (kind === "code") { const code = await fetchCodeBrowse(opts.supabase, opts.sort, fetchLimit); - const merged = mergeBrowse(code.rows, [], opts.sort); + const merged = mergeBrowse(code.rows, [], [], opts.sort); return { data: paginateLibraryEntries(merged, opts.page, opts.limit), total: code.total, @@ -464,26 +545,38 @@ export async function browseLibrary(opts: { opts.sort, fetchLimit ); - const merged = mergeBrowse([], website.rows, opts.sort); + const merged = mergeBrowse([], website.rows, [], opts.sort); return { data: paginateLibraryEntries(merged, opts.page, opts.limit), total: website.total, }; } - const [code, website] = await Promise.all([ + if (kind === "game") { + const game = await fetchGameBrowse(opts.supabase, opts.sort, fetchLimit); + const merged = mergeBrowse([], [], game.rows, opts.sort); + return { + data: paginateLibraryEntries(merged, opts.page, opts.limit), + total: game.total, + }; + } + + const [code, website, game] = await Promise.all([ safeBrowseSource("code", () => fetchCodeBrowse(opts.supabase, opts.sort, fetchLimit) ), safeBrowseSource("website", () => fetchWebsiteBrowse(opts.supabase, opts.sort, fetchLimit) ), + safeBrowseSource("game", () => + fetchGameBrowse(opts.supabase, opts.sort, fetchLimit) + ), ]); - const merged = mergeBrowse(code.rows, website.rows, opts.sort); + const merged = mergeBrowse(code.rows, website.rows, game.rows, opts.sort); return { data: paginateLibraryEntries(merged, opts.page, opts.limit), - total: code.total + website.total, + total: code.total + website.total + game.total, }; } @@ -510,7 +603,7 @@ export async function searchLibrary(opts: { opts.sort, fetchLimit ); - const merged = mergeSearch([], website.rows, opts.search); + const merged = mergeSearch([], website.rows, [], opts.search); return { data: paginateLibraryEntries(merged, opts.page, opts.limit), total: website.total, @@ -518,6 +611,21 @@ export async function searchLibrary(opts: { }; } + if (kind === "game") { + const game = await fetchGameSearch( + opts.supabase, + opts.search, + opts.sort, + fetchLimit + ); + const merged = mergeSearch([], [], game.rows, opts.search); + return { + data: paginateLibraryEntries(merged, opts.page, opts.limit), + total: game.total, + strategy: "game-metadata", + }; + } + if (kind === "code") { if (opts.useHybrid) { try { @@ -526,7 +634,7 @@ export async function searchLibrary(opts: { hybridSearchCount(opts.supabase, opts.search), ]); if (codeRows.length > 0) { - const merged = mergeSearch(codeRows, [], opts.search); + const merged = mergeSearch(codeRows, [], [], opts.search); return { data: paginateLibraryEntries(merged, opts.page, opts.limit), total: codeTotal, @@ -547,7 +655,7 @@ export async function searchLibrary(opts: { opts.sort, fetchLimit ); - const merged = mergeSearch(code.rows, [], opts.search); + const merged = mergeSearch(code.rows, [], [], opts.search); return { data: paginateLibraryEntries(merged, opts.page, opts.limit), total: code.total, @@ -556,39 +664,50 @@ export async function searchLibrary(opts: { } if (opts.useHybrid) { - const [codeRows, website, codeTotal] = await Promise.all([ + const [codeRows, website, game, codeTotal] = await Promise.all([ safeHybridCodeRows(() => fetchCodeHybrid(opts.supabase, opts.search, fetchLimit) ), safeSearchSource("website", () => fetchWebsiteSearch(opts.supabase, opts.search, opts.sort, fetchLimit) ), + safeSearchSource("game", () => + fetchGameSearch(opts.supabase, opts.search, opts.sort, fetchLimit) + ), safeHybridCount(() => hybridSearchCount(opts.supabase, opts.search)), ]); - if (codeRows.length > 0 || website.rows.length > 0) { - const merged = mergeSearch(codeRows, website.rows, opts.search); + if (codeRows.length > 0 || website.rows.length > 0 || game.rows.length > 0) { + const merged = mergeSearch( + codeRows, + website.rows, + game.rows, + opts.search + ); return { data: paginateLibraryEntries(merged, opts.page, opts.limit), - total: codeTotal + website.total, + total: codeTotal + website.total + game.total, strategy: "hybrid", }; } } - const [code, website] = await Promise.all([ + const [code, website, game] = await Promise.all([ safeCodeSearch(() => fetchCodeSearch(opts.supabase, opts.search, opts.sort, fetchLimit) ), safeSearchSource("website", () => fetchWebsiteSearch(opts.supabase, opts.search, opts.sort, fetchLimit) ), + safeSearchSource("game", () => + fetchGameSearch(opts.supabase, opts.search, opts.sort, fetchLimit) + ), ]); - const merged = mergeSearch(code.rows, website.rows, opts.search); + const merged = mergeSearch(code.rows, website.rows, game.rows, opts.search); return { data: paginateLibraryEntries(merged, opts.page, opts.limit), - total: code.total + website.total, + total: code.total + website.total + game.total, strategy: code.strategy, }; } diff --git a/lib/library-types.ts b/lib/library-types.ts index ed55bbd..7d0827f 100644 --- a/lib/library-types.ts +++ b/lib/library-types.ts @@ -1,4 +1,4 @@ -export type LibraryEntryKind = "code" | "website"; +export type LibraryEntryKind = "code" | "website" | "game"; export type LibraryKindFilter = "all" | LibraryEntryKind; @@ -18,6 +18,8 @@ export type LibraryEntry = { /** Website reverse */ slug?: string; target_url?: string; + /** Game reverse */ + game_name?: string; }; export type SortOption = "trending" | "newest" | "oldest"; @@ -76,6 +78,26 @@ export function websiteEntryFromRow(row: { }; } +export function gameEntryFromRow(row: { + slug: string; + game_name: string; + prompt: string; + cached_at: string; + relevance_score?: number; +}): LibraryEntry { + return { + kind: "game", + key: `game:${row.slug}`, + slug: row.slug, + game_name: row.game_name, + prompt: row.prompt, + cached_at: row.cached_at, + title: row.game_name, + href: `/game/${encodeURIComponent(row.slug)}?name=${encodeURIComponent(row.game_name)}`, + relevance_score: row.relevance_score, + }; +} + export function sortLibraryEntries( entries: LibraryEntry[], sort: SortOption diff --git a/lib/parse-game-input.ts b/lib/parse-game-input.ts new file mode 100644 index 0000000..be7fadc --- /dev/null +++ b/lib/parse-game-input.ts @@ -0,0 +1,40 @@ +const SLUG_SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const MAX_SLUG_LEN = 80; +const MAX_NAME_LEN = 120; + +function looksLikeUrl(raw: string): boolean { + const s = raw.trim(); + if (/^https?:\/\//i.test(s)) return true; + if (/^github\.com\//i.test(s)) return true; + if (/^www\./i.test(s)) return true; + if (/\.[a-z]{2,}(\/|$)/i.test(s) && !s.includes(" ")) return true; + return false; +} + +export function nameToSlug(name: string): string { + return name + .toLowerCase() + .replace(/['']/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, MAX_SLUG_LEN); +} + +export function parseGameInput( + raw: string +): { name: string; slug: string } | null { + const name = raw.trim().replace(/\s+/g, " "); + if (!name || name.length > MAX_NAME_LEN) return null; + if (looksLikeUrl(name)) return null; + + const slug = nameToSlug(name); + if (!slug || slug.length < 2) return null; + + return { name, slug }; +} + +export function isValidGameSlug(slug: string): boolean { + const s = slug.trim().toLowerCase(); + if (!s || s.length > MAX_SLUG_LEN) return false; + return SLUG_SEGMENT.test(s); +} diff --git a/lib/site-url.ts b/lib/site-url.ts index d1b1517..dd14cb2 100644 --- a/lib/site-url.ts +++ b/lib/site-url.ts @@ -19,6 +19,14 @@ export function websiteDesignPageUrl(slug: string): string { return `${getSiteBaseUrl()}/designs/${encodeURIComponent(slug)}`; } +export function gameSpecApiUrl(slug: string): string { + return `${getSiteBaseUrl()}/api/game-spec/${encodeURIComponent(slug)}`; +} + +export function gameSpecPageUrl(slug: string): string { + return `${getSiteBaseUrl()}/specs/${encodeURIComponent(slug)}`; +} + function normalizePath(path: string): string { return path.startsWith("/") ? path : `/${path}`; } diff --git a/supabase/migrations/20260814120000_game_reverse_cache.sql b/supabase/migrations/20260814120000_game_reverse_cache.sql new file mode 100644 index 0000000..c7038e2 --- /dev/null +++ b/supabase/migrations/20260814120000_game_reverse_cache.sql @@ -0,0 +1,43 @@ +CREATE TABLE IF NOT EXISTS public.game_reverse_cache ( + slug text PRIMARY KEY, + game_name text NOT NULL, + spec_md text NOT NULL, + prompt text NOT NULL, + cached_at timestamptz NOT NULL DEFAULT now(), + metadata jsonb NULL +); + +ALTER TABLE public.game_reverse_cache ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "anon can read game_reverse_cache" + ON public.game_reverse_cache + FOR SELECT + TO anon, authenticated + USING (true); + +CREATE POLICY "anon can insert game_reverse_cache" + ON public.game_reverse_cache + FOR INSERT + TO anon, authenticated + WITH CHECK (true); + +CREATE POLICY "anon can update game_reverse_cache" + ON public.game_reverse_cache + FOR UPDATE + TO anon, authenticated + USING (true) + WITH CHECK (true); + +CREATE INDEX IF NOT EXISTS game_reverse_cache_cached_at_idx + ON public.game_reverse_cache (cached_at DESC); + +CREATE OR REPLACE VIEW public.library_game_entries +WITH (security_invoker = true) AS +SELECT + slug, + game_name, + left(prompt, 180) AS prompt, + cached_at +FROM public.game_reverse_cache; + +GRANT SELECT ON public.library_game_entries TO anon, authenticated;