Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions app/api/game-spec/[slug]/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
"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",
},
});
}
2 changes: 1 addition & 1 deletion app/api/library/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

Expand Down
164 changes: 164 additions & 0 deletions app/api/reverse-game/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, Promise<GameReverseResult>>();

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<NextResponse> {
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<never>((_, 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 });
}
}
36 changes: 36 additions & 0 deletions app/game/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <GameReversePage gameSlug={slug} gameName={parsed.name} />;
}
44 changes: 44 additions & 0 deletions app/game/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<JsonLd data={gameJsonLd} />
<GameReverseHome />
</>
);
}
6 changes: 6 additions & 0 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: "daily",
priority: 0.9,
},
{
url: `${BASE_URL}/game`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.9,
},
];

let dynamicRoutes: MetadataRoute.Sitemap = [];
Expand Down
64 changes: 64 additions & 0 deletions app/specs/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex min-h-screen flex-col bg-[#FFFDF8] text-zinc-900">
<Navbar />

<main className="mx-auto flex w-full max-w-4xl flex-1 flex-col items-center gap-12 px-4 py-12 sm:px-6">
<h1 className="sr-only">{`${cached.meta.gameName} game spec`}</h1>

<div className="relative w-full max-w-2xl">
<div className="absolute inset-0 translate-x-2 translate-y-2 rounded-xl bg-zinc-900" />
<section className="relative z-10 rounded-xl border-[3px] border-zinc-900 bg-[#fafafa] p-6">
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-zinc-700">
Game spec
</h2>
<p className="mt-0.5 truncate text-xs font-medium text-zinc-500">
{cached.meta.gameName}
</p>
</div>
<div className="group relative shrink-0">
<div className="absolute inset-0 translate-x-0.5 translate-y-0.5 rounded bg-zinc-900" />
<a
href={downloadHref}
download={`${slug}-GAME.md`}
className="relative z-10 inline-flex rounded border-[3px] border-zinc-900 bg-[#ffc480] px-3 py-1.5 text-xs font-medium text-zinc-900 transition-transform group-hover:-translate-x-px group-hover:-translate-y-px"
>
Download GAME.md
</a>
</div>
</div>
<div className="overflow-auto rounded-lg border border-zinc-200 bg-white p-4 text-sm leading-relaxed text-zinc-800">
<PromptMarkdown>{cached.specMd}</PromptMarkdown>
</div>
</section>
</div>
</main>
</div>
);
}
Loading