Browse Source

game engine example (#235)

* game engine

* fixes

* fixes

* fix controls

* og

* fixes

* fixes
Chris Tate 3 tháng trước cách đây
mục cha
commit
f4d13b6612
66 tập tin đã thay đổi với 8628 bổ sung49 xóa
  1. 17 0
      examples/game-engine/.env.example
  2. 48 0
      examples/game-engine/app/api/ai-game/route.ts
  3. 43 0
      examples/game-engine/app/api/ai/route.ts
  4. 125 0
      examples/game-engine/app/api/character-responses/route.ts
  5. 21 0
      examples/game-engine/app/api/environments/route.ts
  6. 21 0
      examples/game-engine/app/api/models/route.ts
  7. 50 0
      examples/game-engine/app/api/text-to-speech/route.ts
  8. 29 0
      examples/game-engine/app/api/upload-environment/route.ts
  9. 29 0
      examples/game-engine/app/api/upload-model/route.ts
  10. BIN
      examples/game-engine/app/favicon.ico
  11. 96 0
      examples/game-engine/app/globals.css
  12. 27 0
      examples/game-engine/app/layout.tsx
  13. BIN
      examples/game-engine/app/opengraph-image.png
  14. 7 0
      examples/game-engine/app/page.tsx
  15. 150 0
      examples/game-engine/components/editor/add-object-button.tsx
  16. 213 0
      examples/game-engine/components/editor/ai-prompt.tsx
  17. 183 0
      examples/game-engine/components/editor/drop-zone.tsx
  18. 86 0
      examples/game-engine/components/editor/json-pane.tsx
  19. 111 0
      examples/game-engine/components/editor/scene-dropdown.tsx
  20. 706 0
      examples/game-engine/components/editor/scene-inspector.tsx
  21. 71 0
      examples/game-engine/components/editor/scene-selector.tsx
  22. 268 0
      examples/game-engine/components/editor/toolbar.tsx
  23. 164 0
      examples/game-engine/components/editor/transform-controls.tsx
  24. 233 0
      examples/game-engine/components/game-engine.tsx
  25. 113 0
      examples/game-engine/components/game/character-interaction.tsx
  26. 73 0
      examples/game-engine/components/game/character.tsx
  27. 77 0
      examples/game-engine/components/game/game-light.tsx
  28. 796 0
      examples/game-engine/components/game/game-primitives.tsx
  29. 52 0
      examples/game-engine/components/game/ground-plane.tsx
  30. 92 0
      examples/game-engine/components/game/media-plane.tsx
  31. 158 0
      examples/game-engine/components/game/model-wrapper.tsx
  32. 529 0
      examples/game-engine/components/game/player.tsx
  33. 80 0
      examples/game-engine/components/game/sound-emitter.tsx
  34. 228 0
      examples/game-engine/components/hud/character-dialog.tsx
  35. 24 0
      examples/game-engine/components/hud/damage-effect.tsx
  36. 46 0
      examples/game-engine/components/hud/damage-sound.tsx
  37. 46 0
      examples/game-engine/components/hud/death-sound.tsx
  38. 51 0
      examples/game-engine/components/hud/game-over.tsx
  39. 58 0
      examples/game-engine/components/hud/health-bar.tsx
  40. 284 0
      examples/game-engine/components/hud/in-game-prompt.tsx
  41. 53 0
      examples/game-engine/components/hud/interaction-prompt.tsx
  42. 16 0
      examples/game-engine/components/hud/loading-spinner.tsx
  43. 191 0
      examples/game-engine/components/hud/touch-controls.tsx
  44. 12 0
      examples/game-engine/eslint.config.js
  45. 71 0
      examples/game-engine/lib/ai-game-prompt.ts
  46. 56 0
      examples/game-engine/lib/ai-prompt.ts
  47. 9 0
      examples/game-engine/lib/bone-utils.ts
  48. 484 0
      examples/game-engine/lib/catalog.ts
  49. 289 0
      examples/game-engine/lib/defaults.ts
  50. 53 0
      examples/game-engine/lib/rate-limit.ts
  51. 89 0
      examples/game-engine/lib/registry.tsx
  52. 224 0
      examples/game-engine/lib/scene-to-spec.ts
  53. 243 0
      examples/game-engine/lib/spec-to-scene.ts
  54. 61 0
      examples/game-engine/lib/speech-queue.ts
  55. 590 0
      examples/game-engine/lib/store.ts
  56. 8 0
      examples/game-engine/lib/touch-state.ts
  57. 246 0
      examples/game-engine/lib/types.ts
  58. 25 0
      examples/game-engine/lib/use-mobile.ts
  59. 6 0
      examples/game-engine/next-env.d.ts
  60. 12 0
      examples/game-engine/next.config.ts
  61. 50 0
      examples/game-engine/package.json
  62. 5 0
      examples/game-engine/postcss.config.mjs
  63. 13 0
      examples/game-engine/tsconfig.json
  64. 415 48
      pnpm-lock.yaml
  65. 1 0
      scripts/generate-og-images.mts
  66. 1 1
      turbo.json

+ 17 - 0
examples/game-engine/.env.example

@@ -0,0 +1,17 @@
+# Vercel AI Gateway
+# Automatically authenticated when deployed on Vercel
+# For local development, get your key from https://vercel.com/ai-gateway
+AI_GATEWAY_API_KEY=
+
+# AI Model Configuration
+# Default: anthropic/claude-sonnet-4-6
+AI_GATEWAY_MODEL=anthropic/claude-sonnet-4-6
+
+# ElevenLabs Text-to-Speech (optional, for NPC dialogue)
+ELEVENLABS_API_KEY=
+
+# Upstash Redis for rate limiting (optional, no-op if not set)
+KV_REST_API_URL=
+KV_REST_API_TOKEN=
+RATE_LIMIT_PER_MINUTE=10
+RATE_LIMIT_PER_DAY=100

+ 48 - 0
examples/game-engine/app/api/ai-game/route.ts

@@ -0,0 +1,48 @@
+import { streamText } from "ai";
+import { gateway } from "@ai-sdk/gateway";
+import { headers } from "next/headers";
+import { generateGameAIPrompt } from "@/lib/ai-game-prompt";
+import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
+
+export async function POST(req: Request) {
+  const headersList = await headers();
+  const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
+
+  const [minuteResult, dailyResult] = await Promise.all([
+    minuteRateLimit.limit(ip),
+    dailyRateLimit.limit(ip),
+  ]);
+
+  if (!minuteResult.success || !dailyResult.success) {
+    const isMinuteLimit = !minuteResult.success;
+    return new Response(
+      JSON.stringify({
+        error: "Rate limit exceeded",
+        message: isMinuteLimit
+          ? "Too many requests. Please wait a moment before trying again."
+          : "Daily limit reached. Please try again tomorrow.",
+      }),
+      {
+        status: 429,
+        headers: { "Content-Type": "application/json" },
+      },
+    );
+  }
+
+  const { prompt, objects, previousPrompts } = await req.json();
+
+  if (!prompt) {
+    return Response.json({ error: "Prompt is required" }, { status: 400 });
+  }
+
+  const sceneObjects = Array.isArray(objects) ? objects : [];
+
+  const result = streamText({
+    model: gateway(
+      process.env.AI_GATEWAY_MODEL || "anthropic/claude-sonnet-4-6",
+    ),
+    prompt: generateGameAIPrompt(prompt, sceneObjects, previousPrompts || []),
+  });
+
+  return result.toTextStreamResponse();
+}

+ 43 - 0
examples/game-engine/app/api/ai/route.ts

@@ -0,0 +1,43 @@
+import { streamText } from "ai";
+import { gateway } from "@ai-sdk/gateway";
+import { headers } from "next/headers";
+import { generateSystemPrompt, generateUserPrompt } from "@/lib/ai-prompt";
+import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
+
+export async function POST(req: Request) {
+  const headersList = await headers();
+  const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
+
+  const [minuteResult, dailyResult] = await Promise.all([
+    minuteRateLimit.limit(ip),
+    dailyRateLimit.limit(ip),
+  ]);
+
+  if (!minuteResult.success || !dailyResult.success) {
+    const isMinuteLimit = !minuteResult.success;
+    return new Response(
+      JSON.stringify({
+        error: "Rate limit exceeded",
+        message: isMinuteLimit
+          ? "Too many requests. Please wait a moment before trying again."
+          : "Daily limit reached. Please try again tomorrow.",
+      }),
+      {
+        status: 429,
+        headers: { "Content-Type": "application/json" },
+      },
+    );
+  }
+
+  const { prompt, spec, previousPrompts } = await req.json();
+
+  const result = streamText({
+    model: gateway(
+      process.env.AI_GATEWAY_MODEL || "anthropic/claude-sonnet-4-6",
+    ),
+    system: generateSystemPrompt(),
+    prompt: generateUserPrompt(prompt, spec, previousPrompts),
+  });
+
+  return result.toTextStreamResponse();
+}

+ 125 - 0
examples/game-engine/app/api/character-responses/route.ts

@@ -0,0 +1,125 @@
+import { streamText } from "ai";
+import { gateway } from "@ai-sdk/gateway";
+import { headers } from "next/headers";
+import speechQueue from "@/lib/speech-queue";
+import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
+
+interface DialogMessage {
+  text: string;
+  audioUrl?: string;
+}
+
+const VOICE_MAP: Record<string, string> = {
+  elder: "pNInz6obpgDQGcFmaJgB",
+  old: "pNInz6obpgDQGcFmaJgB",
+  wise: "pNInz6obpgDQGcFmaJgB",
+  warrior: "AZnzlk1XvdvUeBnXmlld",
+  soldier: "AZnzlk1XvdvUeBnXmlld",
+  guard: "AZnzlk1XvdvUeBnXmlld",
+  child: "MF3mGyEYCl7XYWbV9V6O",
+  young: "MF3mGyEYCl7XYWbV9V6O",
+  merchant: "jBpfuIE2acCO8z3wKNLl",
+  trader: "jBpfuIE2acCO8z3wKNLl",
+  wizard: "IKne3meq5aSn9XLyUdCD",
+  mage: "IKne3meq5aSn9XLyUdCD",
+  magic: "IKne3meq5aSn9XLyUdCD",
+};
+
+function getVoiceIdForRole(role: string): string {
+  const lower = role.toLowerCase();
+  for (const [keyword, voiceId] of Object.entries(VOICE_MAP)) {
+    if (lower.includes(keyword)) return voiceId;
+  }
+  return "ThT5KcBeYPX3keUQqHPh";
+}
+
+export async function POST(req: Request) {
+  const headersList = await headers();
+  const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
+
+  const [minuteResult, dailyResult] = await Promise.all([
+    minuteRateLimit.limit(ip),
+    dailyRateLimit.limit(ip),
+  ]);
+
+  if (!minuteResult.success || !dailyResult.success) {
+    const isMinuteLimit = !minuteResult.success;
+    return new Response(
+      JSON.stringify({
+        error: "Rate limit exceeded",
+        message: isMinuteLimit
+          ? "Too many requests. Please wait a moment before trying again."
+          : "Daily limit reached. Please try again tomorrow.",
+      }),
+      {
+        status: 429,
+        headers: { "Content-Type": "application/json" },
+      },
+    );
+  }
+
+  const { role } = await req.json();
+
+  const prompt = `You are a character with the following role: "${role || "villager"}".
+Generate 2-3 short messages that this character would say when interacted with.
+Keep each message under 100 characters.
+Return ONLY a JSON array of objects with a "text" field for each message.
+Example: [{"text":"Hello traveler! Welcome to our village."}, {"text":"Can I help you with something?"}]`;
+
+  try {
+    const result = await streamText({
+      model: gateway(
+        process.env.AI_GATEWAY_MODEL || "anthropic/claude-sonnet-4-6",
+      ),
+      prompt,
+    });
+
+    let text = "";
+    for await (const chunk of result.textStream) {
+      text += chunk;
+    }
+
+    let messages: DialogMessage[] = [];
+    try {
+      const jsonMatch = text.match(/\[.*\]/s);
+      const jsonString = jsonMatch ? jsonMatch[0] : "[]";
+      messages = JSON.parse(jsonString);
+    } catch {
+      messages = [
+        { text: "Hello there! How can I help you?" },
+        { text: "It's a beautiful day, isn't it?" },
+      ];
+    }
+
+    if (messages.length === 0) {
+      messages = [
+        { text: "Hello there! How can I help you?" },
+        { text: "It's a beautiful day, isn't it?" },
+      ];
+    }
+
+    const hasTTS = !!process.env.ELEVENLABS_API_KEY;
+    if (hasTTS) {
+      const voiceId = getVoiceIdForRole(role || "");
+      const withAudio: DialogMessage[] = [];
+      for (const msg of messages) {
+        try {
+          const audioUrl = await speechQueue.add(msg.text, voiceId);
+          withAudio.push({ ...msg, audioUrl });
+        } catch {
+          withAudio.push(msg);
+        }
+      }
+      return Response.json({ messages: withAudio });
+    }
+
+    return Response.json({ messages });
+  } catch {
+    return Response.json({
+      messages: [
+        { text: "Hello there! How can I help you?" },
+        { text: "It's a beautiful day, isn't it?" },
+      ],
+    });
+  }
+}

+ 21 - 0
examples/game-engine/app/api/environments/route.ts

@@ -0,0 +1,21 @@
+import { NextResponse } from "next/server";
+import { list } from "@vercel/blob";
+
+export async function GET() {
+  try {
+    const { blobs } = await list({ prefix: "game-engine/environments/" });
+    const environments = blobs.map((blob) => ({
+      name: blob.pathname.split("/").pop() || "Unknown",
+      url: blob.url,
+      size: blob.size,
+      uploadedAt: blob.uploadedAt,
+    }));
+    return NextResponse.json({ environments });
+  } catch (error) {
+    console.error("Failed to list environments:", error);
+    return NextResponse.json(
+      { error: "Failed to list environments" },
+      { status: 500 },
+    );
+  }
+}

+ 21 - 0
examples/game-engine/app/api/models/route.ts

@@ -0,0 +1,21 @@
+import { NextResponse } from "next/server";
+import { list } from "@vercel/blob";
+
+export async function GET() {
+  try {
+    const { blobs } = await list({ prefix: "game-engine/models/" });
+    const models = blobs.map((blob) => ({
+      name: blob.pathname.split("/").pop() || "Unknown",
+      url: blob.url,
+      size: blob.size,
+      uploadedAt: blob.uploadedAt,
+    }));
+    return NextResponse.json({ models });
+  } catch (error) {
+    console.error("Failed to list models:", error);
+    return NextResponse.json(
+      { error: "Failed to list models" },
+      { status: 500 },
+    );
+  }
+}

+ 50 - 0
examples/game-engine/app/api/text-to-speech/route.ts

@@ -0,0 +1,50 @@
+export async function POST(req: Request) {
+  const { text, voiceId } = await req.json();
+
+  const apiKey = process.env.ELEVENLABS_API_KEY;
+  if (!apiKey) {
+    return new Response(
+      JSON.stringify({ error: "ELEVENLABS_API_KEY not set" }),
+      {
+        status: 500,
+        headers: { "Content-Type": "application/json" },
+      },
+    );
+  }
+
+  const voice = voiceId || "21m00Tcm4TlvDq8ikWAM";
+
+  const response = await fetch(
+    `https://api.elevenlabs.io/v1/text-to-speech/${voice}`,
+    {
+      method: "POST",
+      headers: {
+        "xi-api-key": apiKey,
+        "Content-Type": "application/json",
+      },
+      body: JSON.stringify({
+        text,
+        model_id: "eleven_monolingual_v1",
+        voice_settings: {
+          stability: 0.5,
+          similarity_boost: 0.75,
+        },
+      }),
+    },
+  );
+
+  if (!response.ok) {
+    return new Response(JSON.stringify({ error: "TTS generation failed" }), {
+      status: 500,
+      headers: { "Content-Type": "application/json" },
+    });
+  }
+
+  const audioBuffer = await response.arrayBuffer();
+  return new Response(audioBuffer, {
+    headers: {
+      "Content-Type": "audio/mpeg",
+      "Cache-Control": "public, max-age=3600",
+    },
+  });
+}

+ 29 - 0
examples/game-engine/app/api/upload-environment/route.ts

@@ -0,0 +1,29 @@
+import { NextResponse } from "next/server";
+import { put } from "@vercel/blob";
+
+export async function POST(request: Request) {
+  try {
+    const formData = await request.formData();
+    const file = formData.get("file") as File;
+
+    if (!file) {
+      return NextResponse.json({ error: "No file provided" }, { status: 400 });
+    }
+
+    const filenameFromForm = formData.get("filename");
+    const filename = filenameFromForm ? String(filenameFromForm) : file.name;
+    const cleanFilename = filename.toLowerCase().replace(/[^a-z0-9.]/g, "-");
+
+    const blob = await put(`game-engine/environments/${cleanFilename}`, file, {
+      access: "public",
+    });
+
+    return NextResponse.json({ url: blob.url, name: cleanFilename });
+  } catch (error) {
+    console.error("Failed to upload environment:", error);
+    return NextResponse.json(
+      { error: "Failed to upload file" },
+      { status: 500 },
+    );
+  }
+}

+ 29 - 0
examples/game-engine/app/api/upload-model/route.ts

@@ -0,0 +1,29 @@
+import { NextResponse } from "next/server";
+import { put } from "@vercel/blob";
+
+export async function POST(request: Request) {
+  try {
+    const formData = await request.formData();
+    const file = formData.get("file") as File;
+
+    if (!file) {
+      return NextResponse.json({ error: "No file provided" }, { status: 400 });
+    }
+
+    const filenameFromForm = formData.get("filename");
+    const filename = filenameFromForm ? String(filenameFromForm) : file.name;
+    const cleanFilename = filename.toLowerCase().replace(/[^a-z0-9.]/g, "-");
+
+    const blob = await put(`game-engine/models/${cleanFilename}`, file, {
+      access: "public",
+    });
+
+    return NextResponse.json({ url: blob.url, name: cleanFilename });
+  } catch (error) {
+    console.error("Failed to upload model:", error);
+    return NextResponse.json(
+      { error: "Failed to upload file" },
+      { status: 500 },
+    );
+  }
+}

BIN
examples/game-engine/app/favicon.ico


+ 96 - 0
examples/game-engine/app/globals.css

@@ -0,0 +1,96 @@
+@import "tailwindcss";
+
+@theme inline {
+  --color-background: #0a0a0a;
+  --color-foreground: #ededed;
+  --color-muted: #1e1e1e;
+  --color-muted-foreground: #888;
+  --color-border: #2a2a2a;
+  --color-input: #1a1a1a;
+  --color-primary: #fff;
+  --color-primary-foreground: #0a0a0a;
+  --color-accent: #333;
+  --color-accent-foreground: #ededed;
+  --color-destructive: #e53935;
+}
+
+html, body {
+  margin: 0;
+  background: var(--color-background);
+  color: var(--color-foreground);
+  font-family: system-ui, -apple-system, sans-serif;
+  overflow: hidden;
+  overscroll-behavior: none;
+  touch-action: none;
+  position: fixed;
+  width: 100%;
+  height: 100dvh;
+  -webkit-user-select: none;
+  user-select: none;
+  -webkit-touch-callout: none;
+  -webkit-tap-highlight-color: transparent;
+}
+
+input, textarea, [contenteditable] {
+  -webkit-user-select: text;
+  user-select: text;
+  -webkit-touch-callout: default;
+  font-size: 16px;
+}
+
+* {
+  box-sizing: border-box;
+  -webkit-tap-highlight-color: transparent;
+}
+
+@media (hover: none) {
+  button, a, [role="button"] {
+    -webkit-tap-highlight-color: transparent;
+  }
+  .hover\:bg-white\/5:hover { background-color: transparent !important; }
+  .hover\:bg-white\/10:hover { background-color: transparent !important; }
+  .hover\:bg-white\/20:hover { background-color: transparent !important; }
+}
+
+canvas {
+  -webkit-user-select: none;
+  user-select: none;
+  -webkit-touch-callout: none;
+  touch-action: none;
+}
+
+:root {
+  --sai-bottom: env(safe-area-inset-bottom, 0px);
+  --sai-top: env(safe-area-inset-top, 0px);
+}
+
+.safe-bottom {
+  padding-bottom: env(safe-area-inset-bottom, 0px);
+}
+
+@keyframes pulse-low {
+  0%, 100% { opacity: 1; }
+  50% { opacity: 0.6; }
+}
+
+.animate-pulse-low {
+  animation: pulse-low 1s ease-in-out infinite;
+}
+
+@keyframes fade-in {
+  from { opacity: 0; }
+  to { opacity: 1; }
+}
+
+.animate-fade-in {
+  animation: fade-in 0.5s ease-out forwards;
+}
+
+@keyframes screen-flash {
+  0% { opacity: 0.7; }
+  100% { opacity: 0; }
+}
+
+.animate-screen-flash {
+  animation: screen-flash 0.4s ease-out forwards;
+}

+ 27 - 0
examples/game-engine/app/layout.tsx

@@ -0,0 +1,27 @@
+import type { Metadata } from "next";
+import "./globals.css";
+
+export const metadata: Metadata = {
+  title: "Game Engine | json-render",
+  description:
+    "Build 3D worlds with AI. A scene editor and game runtime powered by json-render specs and React Three Fiber.",
+  icons: { icon: "/icon.svg" },
+};
+
+export default function RootLayout({
+  children,
+}: {
+  children: React.ReactNode;
+}) {
+  return (
+    <html lang="en">
+      <head>
+        <meta
+          name="viewport"
+          content="width=device-width, initial-scale=1, viewport-fit=cover"
+        />
+      </head>
+      <body>{children}</body>
+    </html>
+  );
+}

BIN
examples/game-engine/app/opengraph-image.png


+ 7 - 0
examples/game-engine/app/page.tsx

@@ -0,0 +1,7 @@
+"use client";
+
+import { GameEngine } from "@/components/game-engine";
+
+export default function Page() {
+  return <GameEngine />;
+}

+ 150 - 0
examples/game-engine/components/editor/add-object-button.tsx

@@ -0,0 +1,150 @@
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+import {
+  Plus,
+  Box,
+  Circle,
+  Cylinder,
+  Triangle,
+  Sun,
+  User,
+  Volume2,
+  Image,
+  Video,
+  Package,
+  Users,
+  Hexagon,
+  Spline,
+  Pentagon,
+  Shapes,
+} from "lucide-react";
+import { useEditorStore } from "@/lib/store";
+import { useIsMobile } from "@/lib/use-mobile";
+import type { ObjectType } from "@/lib/types";
+
+const sections = [
+  {
+    label: "Basic Shapes",
+    items: [
+      { type: "box" as ObjectType, label: "Box", icon: Box },
+      { type: "sphere" as ObjectType, label: "Sphere", icon: Circle },
+      { type: "cylinder" as ObjectType, label: "Cylinder", icon: Cylinder },
+      { type: "cone" as ObjectType, label: "Cone", icon: Triangle },
+      { type: "torus" as ObjectType, label: "Torus", icon: Circle },
+      { type: "plane" as ObjectType, label: "Plane", icon: Box },
+    ],
+  },
+  {
+    label: "Advanced Shapes",
+    items: [
+      { type: "capsule" as ObjectType, label: "Capsule", icon: Cylinder },
+      {
+        type: "tetrahedron" as ObjectType,
+        label: "Tetrahedron",
+        icon: Triangle,
+      },
+      { type: "octahedron" as ObjectType, label: "Octahedron", icon: Circle },
+      {
+        type: "dodecahedron" as ObjectType,
+        label: "Dodecahedron",
+        icon: Circle,
+      },
+      { type: "icosahedron" as ObjectType, label: "Icosahedron", icon: Circle },
+      { type: "knot" as ObjectType, label: "Knot", icon: Circle },
+    ],
+  },
+  {
+    label: "Custom Geometry",
+    items: [
+      { type: "extrude" as ObjectType, label: "Extrude", icon: Hexagon },
+      { type: "tube" as ObjectType, label: "Tube", icon: Spline },
+      { type: "shape" as ObjectType, label: "Shape", icon: Pentagon },
+      { type: "mesh" as ObjectType, label: "Mesh", icon: Shapes },
+    ],
+  },
+  {
+    label: "Environment",
+    items: [
+      { type: "light" as ObjectType, label: "Light", icon: Sun },
+      { type: "sound" as ObjectType, label: "Sound", icon: Volume2 },
+    ],
+  },
+  {
+    label: "Media",
+    items: [
+      { type: "image" as ObjectType, label: "Image", icon: Image },
+      { type: "video" as ObjectType, label: "Video", icon: Video },
+    ],
+  },
+  {
+    label: "Entities",
+    items: [
+      { type: "player" as ObjectType, label: "Player", icon: User },
+      { type: "character" as ObjectType, label: "Character", icon: Users },
+      { type: "model" as ObjectType, label: "Model", icon: Package },
+    ],
+  },
+];
+
+export function AddObjectButton() {
+  const [open, setOpen] = useState(false);
+  const addObject = useEditorStore((s) => s.addObject);
+  const popoverRef = useRef<HTMLDivElement>(null);
+  const isMobile = useIsMobile();
+
+  useEffect(() => {
+    if (!open) return;
+    const handleClickOutside = (e: PointerEvent) => {
+      if (
+        popoverRef.current &&
+        !popoverRef.current.contains(e.target as Node)
+      ) {
+        setOpen(false);
+      }
+    };
+    document.addEventListener("pointerdown", handleClickOutside);
+    return () =>
+      document.removeEventListener("pointerdown", handleClickOutside);
+  }, [open]);
+
+  return (
+    <div className="absolute bottom-4 left-4 z-10" ref={popoverRef}>
+      {open && (
+        <div
+          className={`absolute bottom-14 left-0 ${isMobile ? "w-64" : "w-56"} bg-[#141414] border border-[#2a2a2a] rounded-lg shadow-xl overflow-hidden`}
+        >
+          <div className="max-h-80 overflow-y-auto py-1">
+            {sections.map((section) => (
+              <div key={section.label}>
+                <div className="px-3 py-1.5 text-[9px] font-semibold text-[#555] uppercase tracking-wider">
+                  {section.label}
+                </div>
+                {section.items.map((item) => (
+                  <button
+                    key={item.type}
+                    onClick={() => {
+                      addObject(item.type);
+                      setOpen(false);
+                    }}
+                    className={`w-full flex items-center gap-2.5 px-3 ${isMobile ? "py-2.5" : "py-1.5"} text-xs text-[#aaa] hover:text-white active:text-white hover:bg-white/5 active:bg-white/10 transition-colors`}
+                  >
+                    <item.icon size={isMobile ? 16 : 12} />
+                    {item.label}
+                  </button>
+                ))}
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+      <button
+        onClick={() => setOpen(!open)}
+        className={`${isMobile ? "w-11 h-11" : "w-9 h-9"} flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 active:bg-white/30 text-white transition-colors backdrop-blur-sm`}
+        title="Add object"
+      >
+        <Plus size={isMobile ? 20 : 18} />
+      </button>
+    </div>
+  );
+}

+ 213 - 0
examples/game-engine/components/editor/ai-prompt.tsx

@@ -0,0 +1,213 @@
+"use client";
+
+import { useState, useRef, useMemo } from "react";
+import { MessageSquare, X, ArrowUp, Loader2 } from "lucide-react";
+import { deepMergeSpec } from "@json-render/core";
+import type { Spec } from "@json-render/core";
+import { parse } from "yaml";
+import { useEditorStore } from "@/lib/store";
+import { useIsMobile } from "@/lib/use-mobile";
+import { sceneToSpec } from "@/lib/scene-to-spec";
+import { specToSceneObjects } from "@/lib/spec-to-scene";
+
+function stripFences(text: string): string {
+  return text
+    .replace(/```yaml-edit\s*\n?/g, "")
+    .replace(/```yaml-spec\s*\n?/g, "")
+    .replace(/```\s*$/gm, "")
+    .trim();
+}
+
+export function AIPrompt() {
+  const [open, setOpen] = useState(false);
+  const [prompt, setPrompt] = useState("");
+  const [isProcessing, setIsProcessing] = useState(false);
+  const [message, setMessage] = useState("");
+  const [previousPrompts, setPreviousPrompts] = useState<string[]>([]);
+  const inputRef = useRef<HTMLInputElement>(null);
+  const isMobile = useIsMobile();
+
+  const scenes = useEditorStore((s) => s.scenes);
+  const activeSceneId = useEditorStore((s) => s.activeSceneId);
+  const replaceSceneObjects = useEditorStore((s) => s.replaceSceneObjects);
+  const setIsPromptOpen = useEditorStore((s) => s.setIsPromptOpen);
+
+  const activeScene = useMemo(
+    () => scenes.find((s) => s.id === activeSceneId),
+    [scenes, activeSceneId],
+  );
+
+  const handleSubmit = async () => {
+    if (!prompt.trim() || isProcessing || !activeScene) return;
+
+    setIsProcessing(true);
+    setMessage("Thinking...");
+    setIsPromptOpen(true);
+
+    try {
+      const currentSpec = sceneToSpec(activeScene);
+
+      const res = await fetch("/api/ai", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          prompt,
+          spec: currentSpec,
+          previousPrompts,
+        }),
+      });
+
+      if (!res.ok) {
+        setMessage("Error: " + res.statusText);
+        return;
+      }
+
+      const reader = res.body?.getReader();
+      if (!reader) return;
+
+      const decoder = new TextDecoder();
+      let accumulated = "";
+      let lastAppliedJson = "";
+      const baseSpec = JSON.parse(JSON.stringify(currentSpec));
+
+      while (true) {
+        const { done, value } = await reader.read();
+        if (done) break;
+
+        const chunk = decoder.decode(value, { stream: true });
+        accumulated += chunk;
+
+        if (!chunk.includes("\n")) continue;
+
+        const stripped = stripFences(accumulated);
+        if (!stripped) continue;
+
+        try {
+          const editObj = parse(stripped);
+          if (
+            editObj &&
+            typeof editObj === "object" &&
+            !Array.isArray(editObj)
+          ) {
+            const editJson = JSON.stringify(editObj);
+            if (editJson === lastAppliedJson) continue;
+            lastAppliedJson = editJson;
+
+            const merged = deepMergeSpec(
+              JSON.parse(JSON.stringify(baseSpec)),
+              editObj as Record<string, unknown>,
+            );
+            const patchedSpec = merged as unknown as Spec;
+            const newObjects = specToSceneObjects(patchedSpec);
+            if (newObjects.length > 0) {
+              replaceSceneObjects(newObjects);
+            }
+          }
+        } catch {
+          // Incomplete YAML — wait for more data
+        }
+      }
+
+      // Final parse to catch any remaining content
+      const stripped = stripFences(accumulated);
+      if (stripped) {
+        try {
+          const editObj = parse(stripped);
+          if (
+            editObj &&
+            typeof editObj === "object" &&
+            !Array.isArray(editObj)
+          ) {
+            const merged = deepMergeSpec(
+              JSON.parse(JSON.stringify(baseSpec)),
+              editObj as Record<string, unknown>,
+            );
+            const patchedSpec = merged as unknown as Spec;
+            const newObjects = specToSceneObjects(patchedSpec);
+            if (newObjects.length > 0) {
+              replaceSceneObjects(newObjects);
+            }
+          }
+        } catch {
+          // Parse failed
+        }
+      }
+
+      setPreviousPrompts((prev) => [...prev, prompt]);
+      setMessage("Done");
+      setPrompt("");
+    } catch {
+      setMessage("Error occurred");
+    } finally {
+      setIsProcessing(false);
+      setIsPromptOpen(false);
+      setTimeout(() => setMessage(""), 3000);
+    }
+  };
+
+  if (!open) {
+    return (
+      <button
+        onClick={() => {
+          setOpen(true);
+          setTimeout(() => inputRef.current?.focus(), 100);
+        }}
+        className={`absolute bottom-4 right-4 z-10 ${isMobile ? "w-11 h-11" : "w-9 h-9"} flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 active:bg-white/30 text-white transition-colors backdrop-blur-sm`}
+        title="AI Scene Editor"
+      >
+        <MessageSquare size={isMobile ? 18 : 16} />
+      </button>
+    );
+  }
+
+  return (
+    <div
+      className={`absolute bottom-4 right-4 z-10 ${isMobile ? "left-4 right-4 w-auto" : "w-80"}`}
+    >
+      <div className="bg-[#141414] border border-[#2a2a2a] rounded-lg shadow-xl overflow-hidden">
+        <div className="flex items-center justify-between px-3 py-2 border-b border-[#1e1e1e]">
+          <span className="text-[10px] font-semibold text-[#666] uppercase tracking-wider">
+            AI Scene Editor
+          </span>
+          <button
+            onClick={() => setOpen(false)}
+            className="p-1.5 text-[#666] hover:text-white active:text-white"
+          >
+            <X size={14} />
+          </button>
+        </div>
+        <div className="p-3">
+          <div className="flex gap-2">
+            <input
+              ref={inputRef}
+              type="text"
+              value={prompt}
+              onChange={(e) => setPrompt(e.target.value)}
+              onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
+              placeholder="Describe scene changes..."
+              disabled={isProcessing}
+              className={`flex-1 bg-[#1a1a1a] border border-[#2a2a2a] rounded px-2.5 ${isMobile ? "py-2.5 text-base" : "py-1.5 text-xs"} text-[#ccc] outline-none focus:border-[#555] placeholder:text-[#444] disabled:opacity-50`}
+            />
+            <button
+              onClick={handleSubmit}
+              disabled={isProcessing || !prompt.trim()}
+              className={`${isMobile ? "p-2.5" : "p-1.5"} rounded bg-white/10 hover:bg-white/20 active:bg-white/30 text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors`}
+            >
+              {isProcessing ? (
+                <Loader2 size={14} className="animate-spin" />
+              ) : (
+                <ArrowUp size={14} />
+              )}
+            </button>
+          </div>
+          {message && (
+            <div className="mt-2 text-[10px] text-[#666] flex items-center gap-1.5">
+              {isProcessing && <Loader2 size={10} className="animate-spin" />}
+              {message}
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 183 - 0
examples/game-engine/components/editor/drop-zone.tsx

@@ -0,0 +1,183 @@
+"use client";
+
+import { useState, useCallback, useEffect, useRef } from "react";
+import { Upload } from "lucide-react";
+import { useEditorStore } from "@/lib/store";
+import { useIsMobile } from "@/lib/use-mobile";
+
+export function DropZone() {
+  const [isDragging, setIsDragging] = useState(false);
+  const [isUploading, setIsUploading] = useState(false);
+  const dragCounter = useRef(0);
+  const fileInputRef = useRef<HTMLInputElement>(null);
+  const createCustomObject = useEditorStore((s) => s.createCustomObject);
+  const isPlaying = useEditorStore((s) => s.isPlaying);
+  const isMobile = useIsMobile();
+
+  const hasGlbFiles = useCallback((e: DragEvent) => {
+    if (e.dataTransfer?.types.includes("Files")) return true;
+    return false;
+  }, []);
+
+  const processFiles = useCallback(
+    async (files: File[]) => {
+      const glbFiles = files.filter(
+        (f) =>
+          f.name.endsWith(".glb") ||
+          f.name.endsWith(".gltf") ||
+          f.type === "model/gltf-binary" ||
+          f.type === "model/gltf+json",
+      );
+
+      if (glbFiles.length === 0) return;
+
+      setIsUploading(true);
+
+      for (const file of glbFiles) {
+        try {
+          let modelUrl: string;
+
+          try {
+            const formData = new FormData();
+            formData.append("file", file);
+            formData.append("filename", file.name);
+
+            const res = await fetch("/api/upload-model", {
+              method: "POST",
+              body: formData,
+            });
+
+            if (res.ok) {
+              const data = await res.json();
+              modelUrl = data.url;
+            } else {
+              modelUrl = URL.createObjectURL(file);
+            }
+          } catch {
+            modelUrl = URL.createObjectURL(file);
+          }
+
+          const name = file.name.replace(/\.(glb|gltf)$/i, "");
+          createCustomObject("model", {
+            name: name.charAt(0).toUpperCase() + name.slice(1),
+            modelUrl,
+            position: [0, 0, 0],
+            scale: [1, 1, 1],
+          });
+        } catch (err) {
+          console.error("Failed to add model:", err);
+        }
+      }
+
+      setIsUploading(false);
+    },
+    [createCustomObject],
+  );
+
+  useEffect(() => {
+    if (isPlaying) return;
+
+    const handleDragEnter = (e: DragEvent) => {
+      e.preventDefault();
+      e.stopPropagation();
+      dragCounter.current++;
+      if (hasGlbFiles(e)) {
+        setIsDragging(true);
+      }
+    };
+
+    const handleDragOver = (e: DragEvent) => {
+      e.preventDefault();
+      e.stopPropagation();
+      if (e.dataTransfer) {
+        e.dataTransfer.dropEffect = "copy";
+      }
+    };
+
+    const handleDragLeave = (e: DragEvent) => {
+      e.preventDefault();
+      e.stopPropagation();
+      dragCounter.current--;
+      if (dragCounter.current <= 0) {
+        dragCounter.current = 0;
+        setIsDragging(false);
+      }
+    };
+
+    const handleDrop = async (e: DragEvent) => {
+      e.preventDefault();
+      e.stopPropagation();
+      dragCounter.current = 0;
+      setIsDragging(false);
+
+      if (!e.dataTransfer) return;
+      await processFiles(Array.from(e.dataTransfer.files));
+    };
+
+    window.addEventListener("dragenter", handleDragEnter);
+    window.addEventListener("dragover", handleDragOver);
+    window.addEventListener("dragleave", handleDragLeave);
+    window.addEventListener("drop", handleDrop);
+
+    return () => {
+      window.removeEventListener("dragenter", handleDragEnter);
+      window.removeEventListener("dragover", handleDragOver);
+      window.removeEventListener("dragleave", handleDragLeave);
+      window.removeEventListener("drop", handleDrop);
+    };
+  }, [isPlaying, hasGlbFiles, processFiles]);
+
+  const handleFileInputChange = async (
+    e: React.ChangeEvent<HTMLInputElement>,
+  ) => {
+    if (e.target.files) {
+      await processFiles(Array.from(e.target.files));
+    }
+    if (fileInputRef.current) {
+      fileInputRef.current.value = "";
+    }
+  };
+
+  if (isPlaying) return null;
+
+  return (
+    <>
+      {isDragging && (
+        <div className="absolute inset-0 bg-blue-500/10 border-2 border-dashed border-blue-500/50 rounded-lg flex items-center justify-center z-[6] pointer-events-none">
+          <div className="bg-black/70 backdrop-blur-sm rounded-lg px-6 py-4 flex flex-col items-center gap-2">
+            <Upload className="w-8 h-8 text-blue-400" />
+            <span className="text-sm text-blue-300 font-medium">
+              Drop .glb file to add model
+            </span>
+          </div>
+        </div>
+      )}
+      {isUploading && (
+        <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-[6]">
+          <div className="bg-black/70 backdrop-blur-sm rounded-lg px-6 py-4">
+            <span className="text-sm text-white">Adding model...</span>
+          </div>
+        </div>
+      )}
+      {isMobile && (
+        <>
+          <input
+            ref={fileInputRef}
+            type="file"
+            accept=".glb,.gltf"
+            multiple
+            onChange={handleFileInputChange}
+            className="hidden"
+          />
+          <button
+            onClick={() => fileInputRef.current?.click()}
+            className="absolute bottom-4 left-[4.5rem] z-10 w-11 h-11 flex items-center justify-center rounded-full bg-white/10 active:bg-white/30 text-white transition-colors backdrop-blur-sm"
+            title="Import .glb model"
+          >
+            <Upload size={18} />
+          </button>
+        </>
+      )}
+    </>
+  );
+}

+ 86 - 0
examples/game-engine/components/editor/json-pane.tsx

@@ -0,0 +1,86 @@
+"use client";
+
+import { useMemo, useState, useEffect, type ReactNode } from "react";
+import { stringify } from "yaml";
+import { useEditorStore } from "@/lib/store";
+import { sceneToSpec } from "@/lib/scene-to-spec";
+
+const YAML_TOKEN_RE =
+  /(^[ \t]*[\w][\w.-]*:(?=\s|$))|("(?:\\.|[^"\\])*"|'(?:\\'|[^'\\])*')|(true|false)|(null|~)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?(?=\s|$))/gm;
+
+function highlightYaml(yaml: string): ReactNode[] {
+  const parts: ReactNode[] = [];
+  let lastIndex = 0;
+  let match: RegExpExecArray | null;
+
+  while ((match = YAML_TOKEN_RE.exec(yaml)) !== null) {
+    if (match.index > lastIndex) {
+      parts.push(yaml.slice(lastIndex, match.index));
+    }
+
+    const [full, key, str, bool, nil, num] = match;
+    let color: string;
+    if (key) color = "#c4a7e7";
+    else if (str) color = "#a8d4a2";
+    else if (bool) color = "#f6c177";
+    else if (nil) color = "#6e6a86";
+    else if (num) color = "#ebbcba";
+    else color = "#8a8a8a";
+
+    parts.push(
+      <span key={match.index} style={{ color }}>
+        {full}
+      </span>,
+    );
+    lastIndex = match.index + full.length;
+  }
+
+  if (lastIndex < yaml.length) {
+    parts.push(yaml.slice(lastIndex));
+  }
+
+  return parts;
+}
+
+export function JsonPane() {
+  const [mounted, setMounted] = useState(false);
+  const scenes = useEditorStore((s) => s.scenes);
+  const activeSceneId = useEditorStore((s) => s.activeSceneId);
+
+  useEffect(() => setMounted(true), []);
+
+  const activeScene = useMemo(
+    () => scenes.find((s) => s.id === activeSceneId) || scenes[0],
+    [scenes, activeSceneId],
+  );
+
+  const spec = useMemo(() => {
+    if (!activeScene) return null;
+    return sceneToSpec(activeScene);
+  }, [activeScene]);
+
+  const yaml = useMemo(() => {
+    if (!spec) return "";
+    return stringify(spec, { indent: 2, lineWidth: 0 });
+  }, [spec]);
+
+  const highlighted = useMemo(() => {
+    if (!yaml) return null;
+    return highlightYaml(yaml);
+  }, [yaml]);
+
+  return (
+    <div className="flex flex-col h-full">
+      <div className="h-9 flex items-center px-3 border-b border-[#1e1e1e] flex-shrink-0">
+        <span className="text-[10px] font-semibold text-[#555] uppercase tracking-wider font-mono">
+          Spec
+        </span>
+      </div>
+      <div className="flex-1 overflow-auto">
+        <pre className="p-3 text-[11px] leading-[1.5] text-[#555] font-mono whitespace-pre select-all">
+          {mounted ? highlighted : ""}
+        </pre>
+      </div>
+    </div>
+  );
+}

+ 111 - 0
examples/game-engine/components/editor/scene-dropdown.tsx

@@ -0,0 +1,111 @@
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+import { ChevronDown, Plus, Copy, Trash2 } from "lucide-react";
+import { useEditorStore } from "@/lib/store";
+import { useIsMobile } from "@/lib/use-mobile";
+
+export function SceneDropdown() {
+  const [open, setOpen] = useState(false);
+  const ref = useRef<HTMLDivElement>(null);
+
+  const {
+    scenes,
+    activeSceneId,
+    setActiveScene,
+    createScene,
+    duplicateScene,
+    deleteScene,
+  } = useEditorStore();
+
+  const activeScene = scenes.find((s) => s.id === activeSceneId) || scenes[0];
+  const isMobile = useIsMobile();
+
+  useEffect(() => {
+    function handleClickOutside(e: PointerEvent) {
+      if (ref.current && !ref.current.contains(e.target as Node)) {
+        setOpen(false);
+      }
+    }
+    document.addEventListener("pointerdown", handleClickOutside);
+    return () =>
+      document.removeEventListener("pointerdown", handleClickOutside);
+  }, []);
+
+  return (
+    <div ref={ref} className="relative">
+      <button
+        onClick={() => setOpen(!open)}
+        className="flex items-center gap-1.5 px-2 py-1 rounded text-xs text-[#ccc] hover:text-white hover:bg-white/5 transition-colors"
+      >
+        <span className="truncate max-w-[120px]">{activeScene?.name}</span>
+        <ChevronDown
+          size={12}
+          className={`transition-transform ${open ? "rotate-180" : ""}`}
+        />
+      </button>
+
+      {open && (
+        <div className="absolute top-full left-0 mt-1 w-56 bg-[#1a1a1a] border border-[#2a2a2a] rounded-md shadow-xl z-50 overflow-hidden">
+          <button
+            onClick={() => {
+              createScene(`Scene ${scenes.length + 1}`);
+              setOpen(false);
+            }}
+            className={`w-full flex items-center gap-2 px-3 ${isMobile ? "py-2.5" : "py-2"} text-xs text-[#ccc] hover:bg-white/5 hover:text-white active:bg-white/10 transition-colors`}
+          >
+            <Plus size={12} />
+            New Scene
+          </button>
+
+          <div className="h-px bg-[#2a2a2a]" />
+
+          <div className="max-h-60 overflow-y-auto py-1">
+            {scenes.map((scene) => (
+              <div
+                key={scene.id}
+                className={`group flex items-center justify-between px-3 ${isMobile ? "py-2.5" : "py-1.5"} cursor-pointer text-xs transition-colors ${
+                  scene.id === activeSceneId
+                    ? "bg-white/8 text-white"
+                    : "text-[#888] hover:text-[#ccc] hover:bg-white/3"
+                }`}
+                onClick={() => {
+                  setActiveScene(scene.id);
+                  setOpen(false);
+                }}
+              >
+                <span className="truncate">{scene.name}</span>
+                <div
+                  className={`${isMobile ? "flex" : "hidden group-hover:flex"} items-center gap-0.5`}
+                >
+                  <button
+                    onClick={(e) => {
+                      e.stopPropagation();
+                      duplicateScene(scene.id);
+                    }}
+                    className={`${isMobile ? "p-1.5" : "p-0.5"} text-[#666] hover:text-white active:text-white`}
+                    title="Duplicate"
+                  >
+                    <Copy size={isMobile ? 14 : 10} />
+                  </button>
+                  {scenes.length > 1 && (
+                    <button
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        deleteScene(scene.id);
+                      }}
+                      className={`${isMobile ? "p-1.5" : "p-0.5"} text-[#666] hover:text-red-400 active:text-red-400`}
+                      title="Delete"
+                    >
+                      <Trash2 size={isMobile ? 14 : 10} />
+                    </button>
+                  )}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 706 - 0
examples/game-engine/components/editor/scene-inspector.tsx

@@ -0,0 +1,706 @@
+"use client";
+
+import { useState } from "react";
+import { ChevronDown, ChevronRight, Eye, EyeOff, Trash2 } from "lucide-react";
+import { useEditorStore } from "@/lib/store";
+import { useIsMobile } from "@/lib/use-mobile";
+import type { SceneObject, Physics, LightType } from "@/lib/types";
+
+function Section({
+  label,
+  defaultOpen = true,
+  children,
+}: {
+  label: string;
+  defaultOpen?: boolean;
+  children: React.ReactNode;
+}) {
+  const [open, setOpen] = useState(defaultOpen);
+  return (
+    <div className="border-b border-[#1e1e1e]">
+      <button
+        onClick={() => setOpen(!open)}
+        className="w-full flex items-center gap-1.5 px-3 py-2.5 sm:py-2 text-[10px] font-semibold text-[#666] uppercase tracking-wider hover:text-[#999] active:text-[#999] min-h-[44px] sm:min-h-0"
+      >
+        {open ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
+        {label}
+      </button>
+      {open && (
+        <div className="px-3 pb-3 space-y-2.5 sm:space-y-2">{children}</div>
+      )}
+    </div>
+  );
+}
+
+function Field({
+  label,
+  children,
+}: {
+  label: string;
+  children: React.ReactNode;
+}) {
+  return (
+    <div className="flex items-center gap-2">
+      <label className="text-[10px] text-[#666] w-16 flex-shrink-0">
+        {label}
+      </label>
+      <div className="flex-1">{children}</div>
+    </div>
+  );
+}
+
+function NumberInput({
+  value,
+  onChange,
+  step = 0.1,
+}: {
+  value: number;
+  onChange: (v: number) => void;
+  step?: number;
+}) {
+  return (
+    <input
+      type="number"
+      value={value != null ? Number(value.toFixed(3)) : 0}
+      onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
+      step={step}
+      className="w-full bg-[#1a1a1a] border border-[#2a2a2a] rounded px-1.5 py-1 sm:py-0.5 text-xs sm:text-[11px] text-[#ccc] outline-none focus:border-[#555]"
+    />
+  );
+}
+
+function Vec3Input({
+  value,
+  onChange,
+  labels = ["X", "Y", "Z"],
+}: {
+  value: [number, number, number];
+  onChange: (v: [number, number, number]) => void;
+  labels?: [string, string, string];
+}) {
+  return (
+    <div className="flex gap-1">
+      {[0, 1, 2].map((i) => (
+        <div key={i} className="flex-1">
+          <div className="text-[8px] text-[#555] mb-0.5">{labels[i]}</div>
+          <NumberInput
+            value={value[i] ?? 0}
+            onChange={(v) => {
+              const next = [...value] as [number, number, number];
+              next[i] = v;
+              onChange(next);
+            }}
+          />
+        </div>
+      ))}
+    </div>
+  );
+}
+
+function ColorInput({
+  value,
+  onChange,
+}: {
+  value: string;
+  onChange: (v: string) => void;
+}) {
+  return (
+    <div className="flex items-center gap-1.5">
+      <input
+        type="color"
+        value={value}
+        onChange={(e) => onChange(e.target.value)}
+        className="w-8 h-8 sm:w-6 sm:h-6 rounded border border-[#2a2a2a] cursor-pointer bg-transparent"
+      />
+      <input
+        type="text"
+        value={value}
+        onChange={(e) => onChange(e.target.value)}
+        className="flex-1 bg-[#1a1a1a] border border-[#2a2a2a] rounded px-1.5 py-1 sm:py-0.5 text-xs sm:text-[11px] text-[#ccc] outline-none focus:border-[#555]"
+      />
+    </div>
+  );
+}
+
+function SelectInput({
+  value,
+  onChange,
+  options,
+}: {
+  value: string;
+  onChange: (v: string) => void;
+  options: { value: string; label: string }[];
+}) {
+  return (
+    <select
+      value={value}
+      onChange={(e) => onChange(e.target.value)}
+      className="w-full bg-[#1a1a1a] border border-[#2a2a2a] rounded px-1.5 py-1 sm:py-0.5 text-xs sm:text-[11px] text-[#ccc] outline-none focus:border-[#555]"
+    >
+      {options.map((opt) => (
+        <option key={opt.value} value={opt.value}>
+          {opt.label}
+        </option>
+      ))}
+    </select>
+  );
+}
+
+function CheckboxInput({
+  value,
+  onChange,
+  label,
+}: {
+  value: boolean;
+  onChange: (v: boolean) => void;
+  label: string;
+}) {
+  return (
+    <label className="flex items-center gap-2 text-xs sm:text-[11px] text-[#ccc] cursor-pointer py-0.5">
+      <input
+        type="checkbox"
+        checked={value}
+        onChange={(e) => onChange(e.target.checked)}
+        className="rounded border-[#2a2a2a] w-4 h-4 sm:w-3.5 sm:h-3.5"
+      />
+      {label}
+    </label>
+  );
+}
+
+function ObjectListItem({ obj }: { obj: SceneObject }) {
+  const {
+    selectedObjectId,
+    selectObject,
+    removeObject,
+    toggleObjectVisibility,
+  } = useEditorStore();
+  const isSelected = selectedObjectId === obj.id;
+  const isMobile = useIsMobile();
+
+  return (
+    <div
+      className={`group flex items-center justify-between px-2 ${isMobile ? "py-2" : "py-1"} rounded cursor-pointer text-[11px] ${
+        isSelected
+          ? "bg-white/10 text-white"
+          : "text-[#888] hover:text-[#ccc] hover:bg-white/5"
+      }`}
+      onClick={() => selectObject(obj.id)}
+    >
+      <span className="truncate">{obj.name}</span>
+      <div
+        className={`${isMobile || isSelected ? "flex" : "hidden group-hover:flex"} items-center gap-1`}
+      >
+        <button
+          onClick={(e) => {
+            e.stopPropagation();
+            toggleObjectVisibility(obj.id);
+          }}
+          className={`${isMobile ? "p-1.5" : "p-0.5"} text-[#666] hover:text-white active:text-white`}
+        >
+          {obj.visible ? (
+            <Eye size={isMobile ? 14 : 10} />
+          ) : (
+            <EyeOff size={isMobile ? 14 : 10} />
+          )}
+        </button>
+        <button
+          onClick={(e) => {
+            e.stopPropagation();
+            removeObject(obj.id);
+          }}
+          className={`${isMobile ? "p-1.5" : "p-0.5"} text-[#666] hover:text-red-400 active:text-red-400`}
+        >
+          <Trash2 size={isMobile ? 14 : 10} />
+        </button>
+      </div>
+    </div>
+  );
+}
+
+function TransformEditor({ obj }: { obj: SceneObject }) {
+  const updateObjectTransform = useEditorStore((s) => s.updateObjectTransform);
+  const saveToHistory = useEditorStore((s) => s.saveToHistory);
+
+  return (
+    <Section label="Transform">
+      <Field label="Position">
+        <Vec3Input
+          value={obj.position}
+          onChange={(v) => {
+            updateObjectTransform(obj.id, { position: v });
+            saveToHistory();
+          }}
+        />
+      </Field>
+      <Field label="Rotation">
+        <Vec3Input
+          value={obj.rotation}
+          onChange={(v) => {
+            updateObjectTransform(obj.id, { rotation: v });
+            saveToHistory();
+          }}
+        />
+      </Field>
+      <Field label="Scale">
+        <Vec3Input
+          value={obj.scale}
+          onChange={(v) => {
+            updateObjectTransform(obj.id, { scale: v });
+            saveToHistory();
+          }}
+        />
+      </Field>
+    </Section>
+  );
+}
+
+function MaterialEditor({ obj }: { obj: SceneObject }) {
+  const updateObjectMaterial = useEditorStore((s) => s.updateObjectMaterial);
+
+  return (
+    <Section label="Material">
+      <Field label="Color">
+        <ColorInput
+          value={obj.material.color}
+          onChange={(v) => updateObjectMaterial(obj.id, { color: v })}
+        />
+      </Field>
+      <Field label="Metalness">
+        <NumberInput
+          value={obj.material.metalness}
+          onChange={(v) => updateObjectMaterial(obj.id, { metalness: v })}
+          step={0.05}
+        />
+      </Field>
+      <Field label="Roughness">
+        <NumberInput
+          value={obj.material.roughness}
+          onChange={(v) => updateObjectMaterial(obj.id, { roughness: v })}
+          step={0.05}
+        />
+      </Field>
+      <Field label="Emissive">
+        <ColorInput
+          value={obj.material.emissive}
+          onChange={(v) => updateObjectMaterial(obj.id, { emissive: v })}
+        />
+      </Field>
+      <Field label="Emit Int.">
+        <NumberInput
+          value={obj.material.emissiveIntensity}
+          onChange={(v) =>
+            updateObjectMaterial(obj.id, { emissiveIntensity: v })
+          }
+          step={0.1}
+        />
+      </Field>
+    </Section>
+  );
+}
+
+function PhysicsEditor({ obj }: { obj: SceneObject }) {
+  const updateObjectPhysics = useEditorStore((s) => s.updateObjectPhysics);
+
+  return (
+    <Section label="Physics" defaultOpen={false}>
+      <Field label="Collider">
+        <SelectInput
+          value={obj.physics.colliderType}
+          onChange={(v) =>
+            updateObjectPhysics(obj.id, {
+              colliderType: v as Physics["colliderType"],
+            })
+          }
+          options={[
+            { value: "none", label: "None" },
+            { value: "cuboid", label: "Cuboid" },
+            { value: "ball", label: "Ball" },
+            { value: "capsule", label: "Capsule" },
+          ]}
+        />
+      </Field>
+      {obj.physics.colliderType !== "none" && (
+        <>
+          <Field label="Mass">
+            <NumberInput
+              value={obj.physics.mass}
+              onChange={(v) => updateObjectPhysics(obj.id, { mass: v })}
+            />
+          </Field>
+          <CheckboxInput
+            value={obj.physics.isStatic}
+            onChange={(v) => updateObjectPhysics(obj.id, { isStatic: v })}
+            label="Static"
+          />
+          <Field label="Bounce">
+            <NumberInput
+              value={obj.physics.restitution}
+              onChange={(v) => updateObjectPhysics(obj.id, { restitution: v })}
+              step={0.05}
+            />
+          </Field>
+          <Field label="Friction">
+            <NumberInput
+              value={obj.physics.friction}
+              onChange={(v) => updateObjectPhysics(obj.id, { friction: v })}
+              step={0.05}
+            />
+          </Field>
+        </>
+      )}
+    </Section>
+  );
+}
+
+function DamageEditor({ obj }: { obj: SceneObject }) {
+  const updateDamage = useEditorStore((s) => s.updateDamage);
+  const dmg = obj.damage || { amount: 0, enabled: false };
+
+  return (
+    <Section label="Damage" defaultOpen={false}>
+      <CheckboxInput
+        value={dmg.enabled}
+        onChange={(v) => updateDamage(obj.id, { enabled: v })}
+        label="Enabled"
+      />
+      {dmg.enabled && (
+        <Field label="Amount">
+          <NumberInput
+            value={dmg.amount}
+            onChange={(v) => updateDamage(obj.id, { amount: v })}
+            step={1}
+          />
+        </Field>
+      )}
+    </Section>
+  );
+}
+
+function LightEditor({ obj }: { obj: SceneObject }) {
+  const updateObjectGeneral = useEditorStore((s) => s.updateObjectGeneral);
+
+  return (
+    <Section label="Light">
+      <Field label="Type">
+        <SelectInput
+          value={obj.lightType || "point"}
+          onChange={(v) =>
+            updateObjectGeneral(obj.id, { lightType: v as LightType })
+          }
+          options={[
+            { value: "ambient", label: "Ambient" },
+            { value: "directional", label: "Directional" },
+            { value: "point", label: "Point" },
+            { value: "spot", label: "Spot" },
+          ]}
+        />
+      </Field>
+      <Field label="Intensity">
+        <NumberInput
+          value={obj.intensity ?? 1}
+          onChange={(v) => updateObjectGeneral(obj.id, { intensity: v })}
+          step={0.1}
+        />
+      </Field>
+      {(obj.lightType === "point" || obj.lightType === "spot") && (
+        <Field label="Distance">
+          <NumberInput
+            value={obj.distance ?? 0}
+            onChange={(v) => updateObjectGeneral(obj.id, { distance: v })}
+          />
+        </Field>
+      )}
+      {obj.lightType === "spot" && (
+        <>
+          <Field label="Angle">
+            <NumberInput
+              value={obj.angle ?? Math.PI / 3}
+              onChange={(v) => updateObjectGeneral(obj.id, { angle: v })}
+              step={0.05}
+            />
+          </Field>
+          <Field label="Penumbra">
+            <NumberInput
+              value={obj.penumbra ?? 0}
+              onChange={(v) => updateObjectGeneral(obj.id, { penumbra: v })}
+              step={0.05}
+            />
+          </Field>
+        </>
+      )}
+    </Section>
+  );
+}
+
+function TextInput({
+  value,
+  onChange,
+  placeholder,
+}: {
+  value: string;
+  onChange: (v: string) => void;
+  placeholder?: string;
+}) {
+  return (
+    <input
+      type="text"
+      value={value}
+      onChange={(e) => onChange(e.target.value)}
+      placeholder={placeholder}
+      className="w-full bg-[#1a1a1a] border border-[#2a2a2a] rounded px-1.5 py-1 sm:py-0.5 text-xs sm:text-[11px] text-[#ccc] outline-none focus:border-[#555] placeholder:text-[#444]"
+    />
+  );
+}
+
+function ModelUrlEditor({ obj }: { obj: SceneObject }) {
+  const updateObjectGeneral = useEditorStore((s) => s.updateObjectGeneral);
+
+  return (
+    <Section label="Model">
+      <Field label="URL">
+        <TextInput
+          value={obj.modelUrl || ""}
+          onChange={(v) => updateObjectGeneral(obj.id, { modelUrl: v })}
+          placeholder="/models/example.glb"
+        />
+      </Field>
+      <p className="text-[9px] text-[#555] leading-relaxed">
+        Enter a URL to a .glb or .gltf file. Place files in public/models/ to
+        use local paths like /models/file.glb
+      </p>
+    </Section>
+  );
+}
+
+function CharacterEditor({ obj }: { obj: SceneObject }) {
+  const updateCharacter = useEditorStore((s) => s.updateCharacter);
+  const role = obj.character?.role || "";
+
+  return (
+    <Section label="Character" defaultOpen={false}>
+      <Field label="Role">
+        <TextInput
+          value={role}
+          onChange={(v) => updateCharacter(obj.id, { role: v })}
+          placeholder="e.g. village guard, merchant"
+        />
+      </Field>
+    </Section>
+  );
+}
+
+function EnvironmentEditor() {
+  const scenes = useEditorStore((s) => s.scenes);
+  const activeSceneId = useEditorStore((s) => s.activeSceneId);
+  const updateEnvironmentSettings = useEditorStore(
+    (s) => s.updateEnvironmentSettings,
+  );
+  const activeScene = scenes.find((s) => s.id === activeSceneId);
+  if (!activeScene) return null;
+  const env = activeScene.environmentSettings;
+
+  return (
+    <Section label="Environment" defaultOpen={false}>
+      <Field label="Preset">
+        <SelectInput
+          value={env.preset}
+          onChange={(v) => updateEnvironmentSettings({ preset: v })}
+          options={[
+            { value: "apartment", label: "Apartment" },
+            { value: "city", label: "City" },
+            { value: "dawn", label: "Dawn" },
+            { value: "forest", label: "Forest" },
+            { value: "lobby", label: "Lobby" },
+            { value: "night", label: "Night" },
+            { value: "park", label: "Park" },
+            { value: "studio", label: "Studio" },
+            { value: "sunset", label: "Sunset" },
+            { value: "warehouse", label: "Warehouse" },
+          ]}
+        />
+      </Field>
+      <Field label="Intensity">
+        <NumberInput
+          value={env.intensity}
+          onChange={(v) => updateEnvironmentSettings({ intensity: v })}
+          step={0.1}
+        />
+      </Field>
+      <Field label="Blur">
+        <NumberInput
+          value={env.blur}
+          onChange={(v) => updateEnvironmentSettings({ blur: v })}
+          step={0.1}
+        />
+      </Field>
+    </Section>
+  );
+}
+
+function FogEditor() {
+  const scenes = useEditorStore((s) => s.scenes);
+  const activeSceneId = useEditorStore((s) => s.activeSceneId);
+  const updateSceneSettings = useEditorStore((s) => s.updateSceneSettings);
+  const activeScene = scenes.find((s) => s.id === activeSceneId);
+  if (!activeScene) return null;
+  const fog = activeScene.sceneSettings.fog;
+
+  return (
+    <Section label="Fog" defaultOpen={false}>
+      <CheckboxInput
+        value={fog.enabled}
+        onChange={(v) => updateSceneSettings({ fog: { ...fog, enabled: v } })}
+        label="Enabled"
+      />
+      {fog.enabled && (
+        <>
+          <Field label="Color">
+            <ColorInput
+              value={fog.color}
+              onChange={(v) =>
+                updateSceneSettings({ fog: { ...fog, color: v } })
+              }
+            />
+          </Field>
+          <Field label="Near">
+            <NumberInput
+              value={fog.near}
+              onChange={(v) =>
+                updateSceneSettings({ fog: { ...fog, near: v } })
+              }
+            />
+          </Field>
+          <Field label="Far">
+            <NumberInput
+              value={fog.far}
+              onChange={(v) => updateSceneSettings({ fog: { ...fog, far: v } })}
+            />
+          </Field>
+        </>
+      )}
+    </Section>
+  );
+}
+
+function GridEditor() {
+  const scenes = useEditorStore((s) => s.scenes);
+  const activeSceneId = useEditorStore((s) => s.activeSceneId);
+  const updateSceneSettings = useEditorStore((s) => s.updateSceneSettings);
+  const activeScene = scenes.find((s) => s.id === activeSceneId);
+  if (!activeScene) return null;
+  const grid = activeScene.sceneSettings.grid;
+
+  return (
+    <Section label="Grid" defaultOpen={false}>
+      <CheckboxInput
+        value={grid.visible}
+        onChange={(v) => updateSceneSettings({ grid: { ...grid, visible: v } })}
+        label="Visible"
+      />
+      {grid.visible && (
+        <>
+          <Field label="Size">
+            <NumberInput
+              value={grid.size}
+              onChange={(v) =>
+                updateSceneSettings({ grid: { ...grid, size: v } })
+              }
+              step={0.5}
+            />
+          </Field>
+          <Field label="Divisions">
+            <NumberInput
+              value={grid.divisions}
+              onChange={(v) =>
+                updateSceneSettings({
+                  grid: { ...grid, divisions: Math.max(1, Math.round(v)) },
+                })
+              }
+              step={1}
+            />
+          </Field>
+          <Field label="Fade Dist">
+            <NumberInput
+              value={grid.fadeDistance}
+              onChange={(v) =>
+                updateSceneSettings({ grid: { ...grid, fadeDistance: v } })
+              }
+              step={10}
+            />
+          </Field>
+          <Field label="Fade Str">
+            <NumberInput
+              value={grid.fadeStrength}
+              onChange={(v) =>
+                updateSceneSettings({ grid: { ...grid, fadeStrength: v } })
+              }
+              step={0.1}
+            />
+          </Field>
+        </>
+      )}
+    </Section>
+  );
+}
+
+export function SceneInspector() {
+  const scenes = useEditorStore((s) => s.scenes);
+  const activeSceneId = useEditorStore((s) => s.activeSceneId);
+  const selectedObjectId = useEditorStore((s) => s.selectedObjectId);
+
+  const activeScene = scenes.find((s) => s.id === activeSceneId);
+  const objects = activeScene?.objects ?? [];
+  const selectedObj = selectedObjectId
+    ? objects.find((o) => o.id === selectedObjectId)
+    : null;
+
+  return (
+    <div className="flex flex-col h-full">
+      {/* Object list */}
+      <Section label="Scene Objects">
+        <div className="space-y-0.5 max-h-40 overflow-y-auto">
+          {objects.map((obj) => (
+            <ObjectListItem key={obj.id} obj={obj} />
+          ))}
+        </div>
+      </Section>
+
+      {/* Selected object editors */}
+      {selectedObj && (
+        <>
+          <div className="px-3 py-1.5 border-b border-[#1e1e1e]">
+            <div className="text-[10px] text-[#555] uppercase tracking-wider">
+              {selectedObj.name}
+            </div>
+          </div>
+          <TransformEditor obj={selectedObj} />
+          {selectedObj.type !== "light" &&
+            selectedObj.type !== "player" &&
+            selectedObj.type !== "sound" && (
+              <MaterialEditor obj={selectedObj} />
+            )}
+          {selectedObj.type !== "light" && selectedObj.type !== "player" && (
+            <PhysicsEditor obj={selectedObj} />
+          )}
+          {selectedObj.type !== "light" &&
+            selectedObj.type !== "player" &&
+            selectedObj.type !== "sound" && <DamageEditor obj={selectedObj} />}
+          {selectedObj.type === "light" && <LightEditor obj={selectedObj} />}
+          {(selectedObj.type === "model" ||
+            selectedObj.type === "character") && (
+            <ModelUrlEditor obj={selectedObj} />
+          )}
+          {selectedObj.type === "character" && (
+            <CharacterEditor obj={selectedObj} />
+          )}
+        </>
+      )}
+
+      {/* Scene-level editors */}
+      <EnvironmentEditor />
+      <FogEditor />
+      <GridEditor />
+    </div>
+  );
+}

+ 71 - 0
examples/game-engine/components/editor/scene-selector.tsx

@@ -0,0 +1,71 @@
+"use client";
+
+import { Copy, Trash2, Plus } from "lucide-react";
+import { useEditorStore } from "@/lib/store";
+
+export function SceneSelector() {
+  const {
+    scenes,
+    activeSceneId,
+    setActiveScene,
+    createScene,
+    duplicateScene,
+    deleteScene,
+  } = useEditorStore();
+
+  return (
+    <div className="flex flex-col h-full">
+      <div className="h-9 flex items-center justify-between px-3 border-b border-[#1e1e1e] flex-shrink-0">
+        <span className="text-[10px] font-semibold text-[#555] uppercase tracking-wider font-mono">
+          Scenes
+        </span>
+        <button
+          onClick={() => createScene(`Scene ${scenes.length + 1}`)}
+          className="p-1 text-[#666] hover:text-white transition-colors"
+          title="New scene"
+        >
+          <Plus size={12} />
+        </button>
+      </div>
+      <div className="flex-1 overflow-y-auto py-1">
+        {scenes.map((scene) => (
+          <div
+            key={scene.id}
+            className={`group flex items-center justify-between px-3 py-1.5 cursor-pointer text-xs ${
+              scene.id === activeSceneId
+                ? "bg-white/8 text-white border-l-2 border-white"
+                : "text-[#888] hover:text-[#ccc] border-l-2 border-transparent"
+            }`}
+            onClick={() => setActiveScene(scene.id)}
+          >
+            <span className="truncate">{scene.name}</span>
+            <div className="hidden group-hover:flex items-center gap-0.5">
+              <button
+                onClick={(e) => {
+                  e.stopPropagation();
+                  duplicateScene(scene.id);
+                }}
+                className="p-0.5 text-[#666] hover:text-white"
+                title="Duplicate"
+              >
+                <Copy size={10} />
+              </button>
+              {scenes.length > 1 && (
+                <button
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    deleteScene(scene.id);
+                  }}
+                  className="p-0.5 text-[#666] hover:text-red-400"
+                  title="Delete"
+                >
+                  <Trash2 size={10} />
+                </button>
+              )}
+            </div>
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}

+ 268 - 0
examples/game-engine/components/editor/toolbar.tsx

@@ -0,0 +1,268 @@
+"use client";
+
+import { useState } from "react";
+import {
+  Play,
+  Square,
+  Move,
+  RotateCcw,
+  Maximize,
+  MousePointer,
+  Undo2,
+  Redo2,
+  Eye,
+  User,
+} from "lucide-react";
+import { useEditorStore } from "@/lib/store";
+import { SceneDropdown } from "./scene-dropdown";
+
+export function Toolbar() {
+  const {
+    transformMode,
+    setTransformMode,
+    isPlaying,
+    setIsPlaying,
+    canUndo,
+    canRedo,
+    undo,
+    redo,
+    viewMode,
+    setViewMode,
+  } = useEditorStore();
+
+  const [moreOpen, setMoreOpen] = useState(false);
+
+  return (
+    <div
+      className="relative z-20 flex items-center justify-between px-2 sm:px-3 border-b border-[#1e1e1e] bg-[#0f0f0f] shrink-0"
+      style={{ minHeight: 44, paddingTop: "env(safe-area-inset-top, 0px)" }}
+    >
+      {/* Left: Transform modes */}
+      <div className="flex items-center z-10">
+        {!isPlaying && (
+          <>
+            {/* Mobile: collapsed transform dropdown */}
+            <div className="relative flex items-center sm:hidden">
+              <ToolButton
+                icon={
+                  transformMode === "select" ? (
+                    <MousePointer size={16} />
+                  ) : transformMode === "translate" ? (
+                    <Move size={16} />
+                  ) : transformMode === "rotate" ? (
+                    <RotateCcw size={16} />
+                  ) : (
+                    <Maximize size={16} />
+                  )
+                }
+                active
+                onClick={() => setMoreOpen(!moreOpen)}
+                title="Transform mode"
+                size="mobile"
+              />
+              {moreOpen && (
+                <>
+                  <div
+                    className="fixed inset-0 z-40"
+                    onClick={() => setMoreOpen(false)}
+                  />
+                  <div className="absolute top-full left-0 mt-1 bg-[#1a1a1a] border border-[#2a2a2a] rounded-lg shadow-xl z-50 p-1 flex flex-col gap-0.5 min-w-[140px]">
+                    {(
+                      [
+                        {
+                          mode: "select" as const,
+                          icon: MousePointer,
+                          label: "Select",
+                        },
+                        {
+                          mode: "translate" as const,
+                          icon: Move,
+                          label: "Move",
+                        },
+                        {
+                          mode: "rotate" as const,
+                          icon: RotateCcw,
+                          label: "Rotate",
+                        },
+                        {
+                          mode: "scale" as const,
+                          icon: Maximize,
+                          label: "Scale",
+                        },
+                      ] as const
+                    ).map(({ mode, icon: Icon, label }) => (
+                      <button
+                        key={mode}
+                        onClick={() => {
+                          setTransformMode(mode);
+                          setMoreOpen(false);
+                        }}
+                        className={`flex items-center gap-2.5 px-3 py-2.5 rounded text-xs transition-colors ${
+                          transformMode === mode
+                            ? "bg-white/10 text-white"
+                            : "text-[#888] active:bg-white/5"
+                        }`}
+                      >
+                        <Icon size={14} />
+                        {label}
+                      </button>
+                    ))}
+                  </div>
+                </>
+              )}
+              <ToolButton
+                icon={<Undo2 size={16} />}
+                onClick={undo}
+                disabled={!canUndo}
+                title="Undo"
+                size="mobile"
+              />
+              <ToolButton
+                icon={<Redo2 size={16} />}
+                onClick={redo}
+                disabled={!canRedo}
+                title="Redo"
+                size="mobile"
+              />
+            </div>
+
+            {/* Desktop: individual transform buttons */}
+            <div className="hidden sm:flex items-center gap-1">
+              <ToolButton
+                icon={<MousePointer size={14} />}
+                active={transformMode === "select"}
+                onClick={() => setTransformMode("select")}
+                title="Select (Q)"
+              />
+              <ToolButton
+                icon={<Move size={14} />}
+                active={transformMode === "translate"}
+                onClick={() => setTransformMode("translate")}
+                title="Translate (W)"
+              />
+              <ToolButton
+                icon={<RotateCcw size={14} />}
+                active={transformMode === "rotate"}
+                onClick={() => setTransformMode("rotate")}
+                title="Rotate (E)"
+              />
+              <ToolButton
+                icon={<Maximize size={14} />}
+                active={transformMode === "scale"}
+                onClick={() => setTransformMode("scale")}
+                title="Scale (R)"
+              />
+              <div className="w-px h-5 bg-[#2a2a2a] mx-1" />
+              <ToolButton
+                icon={<Undo2 size={14} />}
+                onClick={undo}
+                disabled={!canUndo}
+                title="Undo (Ctrl+Z)"
+              />
+              <ToolButton
+                icon={<Redo2 size={14} />}
+                onClick={redo}
+                disabled={!canRedo}
+                title="Redo (Ctrl+Shift+Z)"
+              />
+            </div>
+          </>
+        )}
+      </div>
+
+      {/* Center: Scene selector (absolutely centered) */}
+      <div
+        className="absolute inset-0 flex items-center justify-center pointer-events-none"
+        style={{ paddingTop: "env(safe-area-inset-top, 0px)" }}
+      >
+        <div className="pointer-events-auto">
+          {!isPlaying && <SceneDropdown />}
+        </div>
+      </div>
+
+      {/* Right: Play/View controls */}
+      <div className="flex items-center z-10">
+        {isPlaying && (
+          <>
+            {/* Mobile view mode buttons */}
+            <div className="flex items-center sm:hidden">
+              <ToolButton
+                icon={<Eye size={16} />}
+                active={viewMode === "first-person"}
+                onClick={() => setViewMode("first-person")}
+                title="First Person"
+                size="mobile"
+              />
+              <ToolButton
+                icon={<User size={16} />}
+                active={viewMode === "third-person"}
+                onClick={() => setViewMode("third-person")}
+                title="Third Person"
+                size="mobile"
+              />
+            </div>
+            {/* Desktop view mode buttons */}
+            <div className="hidden sm:flex items-center">
+              <ToolButton
+                icon={<Eye size={14} />}
+                active={viewMode === "first-person"}
+                onClick={() => setViewMode("first-person")}
+                title="First Person"
+              />
+              <ToolButton
+                icon={<User size={14} />}
+                active={viewMode === "third-person"}
+                onClick={() => setViewMode("third-person")}
+                title="Third Person"
+              />
+              <div className="w-px h-5 bg-[#2a2a2a] mx-1" />
+            </div>
+          </>
+        )}
+        <button
+          onClick={() => setIsPlaying(!isPlaying)}
+          className="flex items-center gap-1 sm:gap-1.5 px-2.5 sm:px-3 py-1.5 sm:py-1 rounded text-xs font-medium transition-colors bg-white/10 hover:bg-white/20 active:bg-white/20 text-white"
+        >
+          {isPlaying ? <Square size={12} /> : <Play size={12} />}
+          {isPlaying ? "Stop" : "Play"}
+        </button>
+      </div>
+    </div>
+  );
+}
+
+function ToolButton({
+  icon,
+  active,
+  onClick,
+  disabled,
+  title,
+  size,
+}: {
+  icon: React.ReactNode;
+  active?: boolean;
+  onClick: () => void;
+  disabled?: boolean;
+  title?: string;
+  size?: "mobile" | "desktop";
+}) {
+  const isMobileSize = size === "mobile";
+  return (
+    <button
+      onClick={onClick}
+      disabled={disabled}
+      title={title}
+      className={`${isMobileSize ? "p-2.5 min-w-[44px] min-h-[44px]" : "p-1.5"} rounded transition-colors flex items-center justify-center ${
+        active
+          ? isMobileSize
+            ? "text-white"
+            : "bg-white/10 text-white"
+          : disabled
+            ? "text-[#444] cursor-not-allowed"
+            : "text-[#888] hover:text-white hover:bg-white/5 active:bg-white/10"
+      }`}
+    >
+      {icon}
+    </button>
+  );
+}

+ 164 - 0
examples/game-engine/components/editor/transform-controls.tsx

@@ -0,0 +1,164 @@
+"use client";
+
+import { useRef, useEffect, useCallback } from "react";
+import { useThree } from "@react-three/fiber";
+import { TransformControls, OrbitControls } from "@react-three/drei";
+import * as THREE from "three";
+import { useEditorStore } from "@/lib/store";
+
+export function EditorControls() {
+  const isPlaying = useEditorStore((s) => s.isPlaying);
+
+  if (isPlaying) return null;
+
+  return (
+    <>
+      <EditorOrbitControls />
+      <TransformGizmo />
+    </>
+  );
+}
+
+function EditorOrbitControls() {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const orbitRef = useRef<any>(null);
+
+  useEffect(() => {
+    const enable = () => {
+      if (orbitRef.current) orbitRef.current.enabled = false;
+    };
+    const disable = () => {
+      if (orbitRef.current) orbitRef.current.enabled = true;
+    };
+
+    window.addEventListener("transform-start", enable);
+    window.addEventListener("transform-end", disable);
+    return () => {
+      window.removeEventListener("transform-start", enable);
+      window.removeEventListener("transform-end", disable);
+    };
+  }, []);
+
+  return (
+    <OrbitControls
+      ref={orbitRef}
+      makeDefault
+      enableDamping
+      dampingFactor={0.1}
+      minDistance={1}
+      maxDistance={200}
+    />
+  );
+}
+
+function TransformGizmo() {
+  const {
+    selectedObjectId,
+    transformMode,
+    isPlaying,
+    updateObjectTransform,
+    saveToHistory,
+  } = useEditorStore();
+  const { scene } = useThree();
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const controlsRef = useRef<any>(null);
+
+  const findObjectById = useCallback(
+    (id: string): THREE.Object3D | null => {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      let found: any = null;
+      scene.traverse((child) => {
+        if (child.userData?.id === id || child.userData?.objectId === id) {
+          found = child;
+        }
+      });
+      return found;
+    },
+    [scene],
+  );
+
+  useEffect(() => {
+    if (
+      !controlsRef.current ||
+      !selectedObjectId ||
+      isPlaying ||
+      transformMode === "select"
+    ) {
+      if (controlsRef.current) {
+        try {
+          controlsRef.current.detach();
+        } catch {
+          /* may not be attached */
+        }
+      }
+      return;
+    }
+
+    const timer = setTimeout(() => {
+      const obj = findObjectById(selectedObjectId);
+      if (obj && controlsRef.current) {
+        try {
+          controlsRef.current.attach(obj);
+        } catch {
+          /* object may not be ready */
+        }
+      }
+    }, 50);
+
+    return () => clearTimeout(timer);
+  }, [selectedObjectId, transformMode, isPlaying, findObjectById]);
+
+  useEffect(() => {
+    const controls = controlsRef.current;
+    if (!controls) return;
+
+    const onDragStart = () =>
+      window.dispatchEvent(new CustomEvent("transform-start"));
+    const onDragEnd = () =>
+      window.dispatchEvent(new CustomEvent("transform-end"));
+
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    controls.addEventListener("dragging-changed", (e: any) => {
+      if (e.value) onDragStart();
+      else onDragEnd();
+    });
+
+    return () => {
+      controls.removeEventListener("dragging-changed", onDragStart);
+    };
+  }, []);
+
+  const handleChange = useCallback(() => {
+    if (!controlsRef.current || !selectedObjectId) return;
+    const obj = controlsRef.current.object;
+    if (!obj) return;
+
+    updateObjectTransform(selectedObjectId, {
+      position: [obj.position.x, obj.position.y, obj.position.z],
+      rotation: [obj.rotation.x, obj.rotation.y, obj.rotation.z],
+      scale: [obj.scale.x, obj.scale.y, obj.scale.z],
+    });
+  }, [selectedObjectId, updateObjectTransform]);
+
+  const handleMouseUp = useCallback(() => {
+    saveToHistory();
+  }, [saveToHistory]);
+
+  if (!selectedObjectId || isPlaying || transformMode === "select") return null;
+
+  const mode =
+    transformMode === "translate"
+      ? "translate"
+      : transformMode === "rotate"
+        ? "rotate"
+        : "scale";
+
+  return (
+    <TransformControls
+      ref={controlsRef}
+      mode={mode}
+      onObjectChange={handleChange}
+      onMouseUp={handleMouseUp}
+    />
+  );
+}

+ 233 - 0
examples/game-engine/components/game-engine.tsx

@@ -0,0 +1,233 @@
+"use client";
+
+import { useMemo, useEffect, Suspense, useState } from "react";
+import { Canvas } from "@react-three/fiber";
+import { Physics } from "@react-three/rapier";
+import { ThreeRenderer } from "@json-render/react-three-fiber";
+import { Braces, SlidersHorizontal, X } from "lucide-react";
+import { useEditorStore } from "@/lib/store";
+import { useIsMobile } from "@/lib/use-mobile";
+import { registry } from "@/lib/registry";
+import { sceneToSpec } from "@/lib/scene-to-spec";
+import { Toolbar } from "@/components/editor/toolbar";
+import { SceneInspector } from "@/components/editor/scene-inspector";
+import { AddObjectButton } from "@/components/editor/add-object-button";
+import { EditorControls } from "@/components/editor/transform-controls";
+import { JsonPane } from "@/components/editor/json-pane";
+import { AIPrompt } from "@/components/editor/ai-prompt";
+import { HealthBar } from "@/components/hud/health-bar";
+import { DamageEffect } from "@/components/hud/damage-effect";
+import { GameOverScreen } from "@/components/hud/game-over";
+import { CharacterDialog } from "@/components/hud/character-dialog";
+import { InteractionPrompt } from "@/components/hud/interaction-prompt";
+import { InGamePrompt } from "@/components/hud/in-game-prompt";
+import { DamageSound } from "@/components/hud/damage-sound";
+import { DeathSound } from "@/components/hud/death-sound";
+import { LoadingSpinner } from "@/components/hud/loading-spinner";
+import { TouchControls } from "@/components/hud/touch-controls";
+import { CharacterInteraction } from "@/components/game/character-interaction";
+import { DropZone } from "@/components/editor/drop-zone";
+
+export function GameEngine() {
+  const scenes = useEditorStore((s) => s.scenes);
+  const activeSceneId = useEditorStore((s) => s.activeSceneId);
+  const isPlaying = useEditorStore((s) => s.isPlaying);
+  const setIsPlaying = useEditorStore((s) => s.setIsPlaying);
+  const selectedObjectId = useEditorStore((s) => s.selectedObjectId);
+  const health = useEditorStore((s) => s.health);
+  const lastDamageTime = useEditorStore((s) => s.lastDamageTime);
+  const undo = useEditorStore((s) => s.undo);
+  const redo = useEditorStore((s) => s.redo);
+  const removeObject = useEditorStore((s) => s.removeObject);
+  const isMobile = useIsMobile();
+
+  const [leftDrawer, setLeftDrawer] = useState(false);
+  const [rightDrawer, setRightDrawer] = useState(false);
+
+  const activeScene = useMemo(
+    () => scenes.find((s) => s.id === activeSceneId) || scenes[0],
+    [scenes, activeSceneId],
+  );
+
+  const spec = useMemo(() => {
+    if (!activeScene) return null;
+    return sceneToSpec(activeScene);
+  }, [activeScene]);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      const isMod = e.metaKey || e.ctrlKey;
+
+      if (isMod && e.key === "Enter") {
+        e.preventDefault();
+        setIsPlaying(!isPlaying);
+        return;
+      }
+
+      if (isMod && (e.key === "Backspace" || e.key === "Delete")) {
+        e.preventDefault();
+        if (selectedObjectId && !isPlaying) {
+          removeObject(selectedObjectId);
+        }
+        return;
+      }
+
+      if (isMod && e.key === "z" && !e.shiftKey) {
+        e.preventDefault();
+        undo();
+        return;
+      }
+
+      if (isMod && (e.key === "y" || (e.key === "z" && e.shiftKey))) {
+        e.preventDefault();
+        redo();
+        return;
+      }
+    };
+
+    window.addEventListener("keydown", handleKeyDown);
+    return () => window.removeEventListener("keydown", handleKeyDown);
+  }, [isPlaying, setIsPlaying, selectedObjectId, removeObject, undo, redo]);
+
+  useEffect(() => {
+    if (isPlaying) {
+      setLeftDrawer(false);
+      setRightDrawer(false);
+    }
+  }, [isPlaying]);
+
+  const showSidebars = !isPlaying;
+
+  return (
+    <div className="h-dvh w-screen flex flex-col bg-[#0a0a0a] overflow-hidden">
+      <Toolbar />
+
+      <div className="flex flex-1 min-h-0 relative">
+        {/* Left sidebar - JSON Pane (desktop only) */}
+        {showSidebars && (
+          <div className="hidden sm:block w-72 shrink-0 border-r border-[#1e1e1e] bg-[#0f0f0f] overflow-hidden">
+            <JsonPane />
+          </div>
+        )}
+
+        {/* Mobile left drawer */}
+        {showSidebars && (
+          <>
+            <div
+              className={`fixed inset-0 bg-black/50 z-30 transition-opacity duration-200 ${leftDrawer ? "opacity-100" : "opacity-0 pointer-events-none"}`}
+              onClick={() => setLeftDrawer(false)}
+            />
+            <div
+              className={`fixed left-0 top-0 bottom-0 w-72 max-w-[85vw] bg-[#0f0f0f] border-r border-[#1e1e1e] z-40 overflow-hidden transition-transform duration-200 ease-out ${leftDrawer ? "translate-x-0" : "-translate-x-full"}`}
+              style={{ paddingTop: "env(safe-area-inset-top, 0px)" }}
+            >
+              <div className="flex items-center justify-between px-3 py-2 border-b border-[#1e1e1e]">
+                <span className="text-[10px] font-semibold text-[#666] uppercase tracking-wider">
+                  JSON Spec
+                </span>
+                <button
+                  onClick={() => setLeftDrawer(false)}
+                  className="p-1.5 text-[#666] hover:text-white active:text-white"
+                >
+                  <X size={14} />
+                </button>
+              </div>
+              <JsonPane />
+            </div>
+          </>
+        )}
+
+        {/* Canvas area */}
+        <div className="flex-1 relative min-w-0 safe-bottom">
+          <DropZone />
+          <Canvas
+            shadows
+            camera={{ position: [5, 5, 5], fov: 50 }}
+            style={{ width: "100%", height: "100%", touchAction: "none" }}
+          >
+            <Suspense fallback={null}>
+              {isPlaying ? (
+                <Physics gravity={[0, -9.81, 0]}>
+                  <ThreeRenderer spec={spec} registry={registry} />
+                  <CharacterInteraction />
+                </Physics>
+              ) : (
+                <>
+                  <ThreeRenderer spec={spec} registry={registry} />
+                  <EditorControls />
+                </>
+              )}
+            </Suspense>
+          </Canvas>
+
+          {/* Mobile drawer toggle buttons */}
+          {showSidebars && (
+            <>
+              <button
+                onClick={() => setLeftDrawer(true)}
+                className="sm:hidden absolute top-2 left-2 z-10 w-11 h-11 flex items-center justify-center rounded-full bg-white/10 active:bg-white/30 text-white transition-colors backdrop-blur-sm"
+              >
+                <Braces size={18} />
+              </button>
+              <button
+                onClick={() => setRightDrawer(true)}
+                className="sm:hidden absolute top-2 right-2 z-10 w-11 h-11 flex items-center justify-center rounded-full bg-white/10 active:bg-white/30 text-white transition-colors backdrop-blur-sm"
+              >
+                <SlidersHorizontal size={18} />
+              </button>
+            </>
+          )}
+
+          {!isPlaying && <AddObjectButton />}
+          {!isPlaying && <AIPrompt />}
+
+          <LoadingSpinner />
+
+          {isPlaying && <HealthBar />}
+          {isPlaying && <DamageEffect lastDamageTime={lastDamageTime} />}
+          {isPlaying && health <= 0 && <GameOverScreen />}
+          {isPlaying && <CharacterDialog />}
+          {isPlaying && <InteractionPrompt />}
+          {isPlaying && <InGamePrompt />}
+          {isPlaying && isMobile && <TouchControls />}
+          <DamageSound />
+          <DeathSound />
+        </div>
+
+        {/* Right sidebar - Inspector (desktop only) */}
+        {showSidebars && (
+          <div className="hidden sm:block w-72 shrink-0 border-l border-[#1e1e1e] bg-[#0f0f0f] overflow-y-auto">
+            <SceneInspector />
+          </div>
+        )}
+
+        {/* Mobile right drawer */}
+        {showSidebars && (
+          <>
+            <div
+              className={`fixed inset-0 bg-black/50 z-30 transition-opacity duration-200 ${rightDrawer ? "opacity-100" : "opacity-0 pointer-events-none"}`}
+              onClick={() => setRightDrawer(false)}
+            />
+            <div
+              className={`fixed right-0 top-0 bottom-0 w-72 max-w-[85vw] bg-[#0f0f0f] border-l border-[#1e1e1e] z-40 overflow-y-auto transition-transform duration-200 ease-out ${rightDrawer ? "translate-x-0" : "translate-x-full"}`}
+              style={{ paddingTop: "env(safe-area-inset-top, 0px)" }}
+            >
+              <div className="flex items-center justify-between px-3 py-2 border-b border-[#1e1e1e]">
+                <span className="text-[10px] font-semibold text-[#666] uppercase tracking-wider">
+                  Inspector
+                </span>
+                <button
+                  onClick={() => setRightDrawer(false)}
+                  className="p-1.5 text-[#666] hover:text-white active:text-white"
+                >
+                  <X size={14} />
+                </button>
+              </div>
+              <SceneInspector />
+            </div>
+          </>
+        )}
+      </div>
+    </div>
+  );
+}

+ 113 - 0
examples/game-engine/components/game/character-interaction.tsx

@@ -0,0 +1,113 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { useThree } from "@react-three/fiber";
+import * as THREE from "three";
+import { useEditorStore } from "@/lib/store";
+
+export function CharacterInteraction() {
+  const [nearbyCharacter, setNearbyCharacter] = useState<{
+    id: string;
+    role: string;
+  } | null>(null);
+  const { camera } = useThree();
+  const scenes = useEditorStore((s) => s.scenes);
+  const activeSceneId = useEditorStore((s) => s.activeSceneId);
+  const isPlaying = useEditorStore((s) => s.isPlaying);
+
+  const activeScene = scenes.find((s) => s.id === activeSceneId) || scenes[0];
+  const objects = useMemo(
+    () => (activeScene ? activeScene.objects : []),
+    [activeScene],
+  );
+
+  useEffect(() => {
+    if (!isPlaying) {
+      setNearbyCharacter(null);
+      return;
+    }
+
+    const checkProximity = () => {
+      if (!camera) return;
+
+      const cameraPos = new THREE.Vector3();
+      camera.getWorldPosition(cameraPos);
+
+      const characters = objects.filter(
+        (obj) => obj.type === "character" && obj.visible,
+      );
+
+      let closest: { id: string; role: string } | null = null;
+      let closestDist = Infinity;
+
+      for (const character of characters) {
+        const charPos = new THREE.Vector3(...character.position);
+        const dist = cameraPos.distanceTo(charPos);
+
+        if (dist < 3 && dist < closestDist) {
+          closestDist = dist;
+          closest = {
+            id: character.id,
+            role: character.character?.role || "villager",
+          };
+        }
+      }
+
+      if (closest) {
+        if (!nearbyCharacter || nearbyCharacter.id !== closest.id) {
+          setNearbyCharacter(closest);
+          window.dispatchEvent(
+            new CustomEvent("character-nearby", {
+              detail: { id: closest.id, role: closest.role },
+            }),
+          );
+        }
+      } else if (nearbyCharacter) {
+        setNearbyCharacter(null);
+        window.dispatchEvent(new CustomEvent("character-far"));
+      }
+    };
+
+    const intervalId = setInterval(checkProximity, 200);
+
+    const triggerInteract = () => {
+      if (nearbyCharacter) {
+        window.dispatchEvent(
+          new CustomEvent("character-interact", {
+            detail: {
+              id: nearbyCharacter.id,
+              role: nearbyCharacter.role,
+            },
+          }),
+        );
+      }
+    };
+
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === "e" || e.key === "E") {
+        triggerInteract();
+      }
+    };
+
+    const handleRequestInteract = () => {
+      triggerInteract();
+    };
+
+    window.addEventListener("keydown", handleKeyDown);
+    window.addEventListener(
+      "request-character-interact",
+      handleRequestInteract,
+    );
+
+    return () => {
+      clearInterval(intervalId);
+      window.removeEventListener("keydown", handleKeyDown);
+      window.removeEventListener(
+        "request-character-interact",
+        handleRequestInteract,
+      );
+    };
+  }, [camera, isPlaying, nearbyCharacter, objects]);
+
+  return null;
+}

+ 73 - 0
examples/game-engine/components/game/character.tsx

@@ -0,0 +1,73 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { useFrame, useThree } from "@react-three/fiber";
+import { useGLTF } from "@react-three/drei";
+import * as THREE from "three";
+import { useEditorStore } from "@/lib/store";
+
+interface GameCharacterProps {
+  position?: [number, number, number] | null;
+  rotation?: [number, number, number] | null;
+  scale?: [number, number, number] | null;
+  castShadow?: boolean | null;
+  receiveShadow?: boolean | null;
+  modelUrl: string;
+  role?: string | null;
+  physics?: {
+    mass?: number | null;
+    isStatic?: boolean | null;
+    restitution?: number | null;
+    friction?: number | null;
+    colliderType?: string | null;
+  } | null;
+  objectId?: string | null;
+}
+
+export function GameCharacter({
+  position,
+  rotation,
+  scale,
+  modelUrl,
+}: GameCharacterProps) {
+  const isPlaying = useEditorStore((s) => s.isPlaying);
+  const pos: [number, number, number] = position ?? [0, 0, 0];
+  const rot: [number, number, number] = rotation ?? [0, 0, 0];
+  const scl: [number, number, number] = scale ?? [1, 1, 1];
+  const groupRef = useRef<THREE.Group>(null);
+  const { camera } = useThree();
+  const [isNear, setIsNear] = useState(false);
+
+  useFrame(() => {
+    if (!isPlaying || !groupRef.current) return;
+    const dist = groupRef.current.position.distanceTo(camera.position);
+    setIsNear(dist < 3);
+  });
+
+  if (!modelUrl) {
+    return (
+      <group position={pos} rotation={rot} scale={scl} ref={groupRef}>
+        <mesh castShadow>
+          <capsuleGeometry args={[0.3, 0.8, 8, 16]} />
+          <meshStandardMaterial color="#e74c3c" />
+        </mesh>
+      </group>
+    );
+  }
+
+  return (
+    <group ref={groupRef} position={pos} rotation={rot} scale={scl}>
+      <CharacterModel url={modelUrl} />
+      {isPlaying && isNear && (
+        <sprite position={[0, 2.2, 0]} scale={[0.5, 0.5, 0.5]}>
+          <spriteMaterial color="#ffffff" opacity={0.8} transparent />
+        </sprite>
+      )}
+    </group>
+  );
+}
+
+function CharacterModel({ url }: { url: string }) {
+  const { scene } = useGLTF(url);
+  return <primitive object={scene.clone()} />;
+}

+ 77 - 0
examples/game-engine/components/game/game-light.tsx

@@ -0,0 +1,77 @@
+"use client";
+
+interface GameLightProps {
+  position?: [number, number, number] | null;
+  rotation?: [number, number, number] | null;
+  scale?: [number, number, number] | null;
+  lightType: "ambient" | "directional" | "point" | "spot";
+  color?: string | null;
+  intensity?: number | null;
+  distance?: number | null;
+  decay?: number | null;
+  angle?: number | null;
+  penumbra?: number | null;
+  castShadow?: boolean | null;
+  objectId?: string | null;
+}
+
+export function GameLight({
+  position,
+  lightType,
+  color,
+  intensity,
+  distance,
+  decay,
+  angle,
+  penumbra,
+  castShadow,
+}: GameLightProps) {
+  const c = color ?? "#ffffff";
+  const i = intensity ?? 1;
+
+  switch (lightType) {
+    case "ambient":
+      return <ambientLight color={c} intensity={i} />;
+    case "directional":
+      return (
+        <directionalLight
+          position={position ?? [5, 10, 5]}
+          color={c}
+          intensity={i}
+          castShadow={castShadow ?? true}
+          shadow-mapSize-width={2048}
+          shadow-mapSize-height={2048}
+          shadow-camera-far={50}
+          shadow-camera-left={-20}
+          shadow-camera-right={20}
+          shadow-camera-top={20}
+          shadow-camera-bottom={-20}
+        />
+      );
+    case "spot":
+      return (
+        <spotLight
+          position={position ?? [0, 5, 0]}
+          color={c}
+          intensity={i}
+          distance={distance ?? 0}
+          decay={decay ?? 2}
+          angle={angle ?? Math.PI / 3}
+          penumbra={penumbra ?? 0}
+          castShadow={castShadow ?? true}
+        />
+      );
+    case "point":
+    default:
+      return (
+        <pointLight
+          position={position ?? [0, 3, 0]}
+          color={c}
+          intensity={i}
+          distance={distance ?? 0}
+          decay={decay ?? 2}
+          castShadow={castShadow ?? false}
+        />
+      );
+  }
+}

+ 796 - 0
examples/game-engine/components/game/game-primitives.tsx

@@ -0,0 +1,796 @@
+"use client";
+
+import { useRef, useEffect } from "react";
+import * as THREE from "three";
+import { RigidBody } from "@react-three/rapier";
+import type { RapierRigidBody } from "@react-three/rapier";
+import { useEditorStore } from "@/lib/store";
+
+interface PhysicsProps {
+  mass?: number | null;
+  isStatic?: boolean | null;
+  restitution?: number | null;
+  friction?: number | null;
+  colliderType?: string | null;
+}
+
+interface DamageProps {
+  amount?: number | null;
+  enabled?: boolean | null;
+}
+
+interface MaterialProps {
+  color?: string | null;
+  metalness?: number | null;
+  roughness?: number | null;
+  emissive?: string | null;
+  emissiveIntensity?: number | null;
+  opacity?: number | null;
+  transparent?: boolean | null;
+  wireframe?: boolean | null;
+}
+
+interface GamePrimitiveProps {
+  position?: [number, number, number] | null;
+  rotation?: [number, number, number] | null;
+  scale?: [number, number, number] | null;
+  castShadow?: boolean | null;
+  receiveShadow?: boolean | null;
+  material?: MaterialProps | null;
+  physics?: PhysicsProps | null;
+  damage?: DamageProps | null;
+  objectId?: string | null;
+  children?: React.ReactNode;
+}
+
+function buildMaterialProps(mat?: MaterialProps | null) {
+  if (!mat) return { color: "#888888" };
+  return {
+    color: mat.color ?? "#888888",
+    metalness: mat.metalness ?? 0,
+    roughness: mat.roughness ?? 0.5,
+    ...(mat.emissive ? { emissive: mat.emissive } : {}),
+    ...(mat.emissiveIntensity
+      ? { emissiveIntensity: mat.emissiveIntensity }
+      : {}),
+    ...(mat.opacity != null ? { opacity: mat.opacity, transparent: true } : {}),
+    ...(mat.wireframe ? { wireframe: true } : {}),
+  };
+}
+
+function PhysicsWrapper({
+  physics,
+  damage,
+  position,
+  rotation,
+  scale,
+  children,
+}: {
+  physics?: PhysicsProps | null;
+  damage?: DamageProps | null;
+  position: [number, number, number];
+  rotation: [number, number, number];
+  scale: [number, number, number];
+  children: React.ReactNode;
+}) {
+  const bodyRef = useRef<RapierRigidBody>(null);
+  const takeDamage = useEditorStore((s) => s.takeDamage);
+  const isPlaying = useEditorStore((s) => s.isPlaying);
+
+  const hasPhysics =
+    physics && physics.colliderType && physics.colliderType !== "none";
+  const hasDamage = damage?.enabled && (damage.amount ?? 0) > 0;
+
+  if (!hasPhysics || !isPlaying) {
+    return (
+      <group position={position} rotation={rotation} scale={scale}>
+        {children}
+      </group>
+    );
+  }
+
+  return (
+    <RigidBody
+      ref={bodyRef}
+      type={physics.isStatic ? "fixed" : "dynamic"}
+      position={position}
+      rotation={rotation}
+      mass={physics.mass ?? 1}
+      restitution={physics.restitution ?? 0.2}
+      friction={physics.friction ?? 0.5}
+      onCollisionEnter={
+        hasDamage
+          ? () => {
+              takeDamage(damage!.amount!);
+            }
+          : undefined
+      }
+    >
+      <group scale={scale}>{children}</group>
+    </RigidBody>
+  );
+}
+
+export function GameBox({
+  position,
+  rotation,
+  scale,
+  castShadow,
+  receiveShadow,
+  material,
+  physics,
+  damage,
+  width,
+  height,
+  depth,
+}: GamePrimitiveProps & {
+  width?: number | null;
+  height?: number | null;
+  depth?: number | null;
+}) {
+  const pos: [number, number, number] = position ?? [0, 0, 0];
+  const rot: [number, number, number] = rotation ?? [0, 0, 0];
+  const scl: [number, number, number] = scale ?? [1, 1, 1];
+
+  return (
+    <PhysicsWrapper
+      physics={physics}
+      damage={damage}
+      position={pos}
+      rotation={rot}
+      scale={scl}
+    >
+      <mesh
+        castShadow={castShadow ?? false}
+        receiveShadow={receiveShadow ?? false}
+      >
+        <boxGeometry args={[width ?? 1, height ?? 1, depth ?? 1]} />
+        <meshStandardMaterial {...buildMaterialProps(material)} />
+      </mesh>
+    </PhysicsWrapper>
+  );
+}
+
+export function GameSphere({
+  position,
+  rotation,
+  scale,
+  castShadow,
+  receiveShadow,
+  material,
+  physics,
+  damage,
+  radius,
+  widthSegments,
+  heightSegments,
+}: GamePrimitiveProps & {
+  radius?: number | null;
+  widthSegments?: number | null;
+  heightSegments?: number | null;
+}) {
+  const pos: [number, number, number] = position ?? [0, 0, 0];
+  const rot: [number, number, number] = rotation ?? [0, 0, 0];
+  const scl: [number, number, number] = scale ?? [1, 1, 1];
+
+  return (
+    <PhysicsWrapper
+      physics={physics}
+      damage={damage}
+      position={pos}
+      rotation={rot}
+      scale={scl}
+    >
+      <mesh
+        castShadow={castShadow ?? false}
+        receiveShadow={receiveShadow ?? false}
+      >
+        <sphereGeometry
+          args={[radius ?? 1, widthSegments ?? 32, heightSegments ?? 16]}
+        />
+        <meshStandardMaterial {...buildMaterialProps(material)} />
+      </mesh>
+    </PhysicsWrapper>
+  );
+}
+
+export function GameCylinder({
+  position,
+  rotation,
+  scale,
+  castShadow,
+  receiveShadow,
+  material,
+  physics,
+  damage,
+  radiusTop,
+  radiusBottom,
+  height,
+  radialSegments,
+}: GamePrimitiveProps & {
+  radiusTop?: number | null;
+  radiusBottom?: number | null;
+  height?: number | null;
+  radialSegments?: number | null;
+}) {
+  const pos: [number, number, number] = position ?? [0, 0, 0];
+  const rot: [number, number, number] = rotation ?? [0, 0, 0];
+  const scl: [number, number, number] = scale ?? [1, 1, 1];
+
+  return (
+    <PhysicsWrapper
+      physics={physics}
+      damage={damage}
+      position={pos}
+      rotation={rot}
+      scale={scl}
+    >
+      <mesh
+        castShadow={castShadow ?? false}
+        receiveShadow={receiveShadow ?? false}
+      >
+        <cylinderGeometry
+          args={[
+            radiusTop ?? 1,
+            radiusBottom ?? 1,
+            height ?? 1,
+            radialSegments ?? 32,
+          ]}
+        />
+        <meshStandardMaterial {...buildMaterialProps(material)} />
+      </mesh>
+    </PhysicsWrapper>
+  );
+}
+
+export function GameCone({
+  position,
+  rotation,
+  scale,
+  castShadow,
+  receiveShadow,
+  material,
+  physics,
+  damage,
+  radius,
+  height,
+  radialSegments,
+}: GamePrimitiveProps & {
+  radius?: number | null;
+  height?: number | null;
+  radialSegments?: number | null;
+}) {
+  const pos: [number, number, number] = position ?? [0, 0, 0];
+  const rot: [number, number, number] = rotation ?? [0, 0, 0];
+  const scl: [number, number, number] = scale ?? [1, 1, 1];
+
+  return (
+    <PhysicsWrapper
+      physics={physics}
+      damage={damage}
+      position={pos}
+      rotation={rot}
+      scale={scl}
+    >
+      <mesh
+        castShadow={castShadow ?? false}
+        receiveShadow={receiveShadow ?? false}
+      >
+        <coneGeometry args={[radius ?? 1, height ?? 1, radialSegments ?? 32]} />
+        <meshStandardMaterial {...buildMaterialProps(material)} />
+      </mesh>
+    </PhysicsWrapper>
+  );
+}
+
+export function GameTorus({
+  position,
+  rotation,
+  scale,
+  castShadow,
+  receiveShadow,
+  material,
+  physics,
+  damage,
+  radius,
+  tube,
+  radialSegments,
+  tubularSegments,
+}: GamePrimitiveProps & {
+  radius?: number | null;
+  tube?: number | null;
+  radialSegments?: number | null;
+  tubularSegments?: number | null;
+}) {
+  const pos: [number, number, number] = position ?? [0, 0, 0];
+  const rot: [number, number, number] = rotation ?? [0, 0, 0];
+  const scl: [number, number, number] = scale ?? [1, 1, 1];
+
+  return (
+    <PhysicsWrapper
+      physics={physics}
+      damage={damage}
+      position={pos}
+      rotation={rot}
+      scale={scl}
+    >
+      <mesh
+        castShadow={castShadow ?? false}
+        receiveShadow={receiveShadow ?? false}
+      >
+        <torusGeometry
+          args={[
+            radius ?? 1,
+            tube ?? 0.4,
+            radialSegments ?? 16,
+            tubularSegments ?? 48,
+          ]}
+        />
+        <meshStandardMaterial {...buildMaterialProps(material)} />
+      </mesh>
+    </PhysicsWrapper>
+  );
+}
+
+export function GamePlane({
+  position,
+  rotation,
+  scale,
+  castShadow,
+  receiveShadow,
+  material,
+  physics,
+  damage,
+  width,
+  height,
+}: GamePrimitiveProps & { width?: number | null; height?: number | null }) {
+  const pos: [number, number, number] = position ?? [0, 0, 0];
+  const rot: [number, number, number] = rotation ?? [0, 0, 0];
+  const scl: [number, number, number] = scale ?? [1, 1, 1];
+
+  return (
+    <PhysicsWrapper
+      physics={physics}
+      damage={damage}
+      position={pos}
+      rotation={rot}
+      scale={scl}
+    >
+      <mesh
+        castShadow={castShadow ?? false}
+        receiveShadow={receiveShadow ?? true}
+      >
+        <planeGeometry args={[width ?? 1, height ?? 1]} />
+        <meshStandardMaterial
+          {...buildMaterialProps(material)}
+          side={THREE.DoubleSide}
+        />
+      </mesh>
+    </PhysicsWrapper>
+  );
+}
+
+export function GameCapsule({
+  position,
+  rotation,
+  scale,
+  castShadow,
+  receiveShadow,
+  material,
+  physics,
+  damage,
+  radius,
+  length,
+}: GamePrimitiveProps & {
+  radius?: number | null;
+  length?: number | null;
+  capSegments?: number | null;
+  radialSegments?: number | null;
+}) {
+  const pos: [number, number, number] = position ?? [0, 0, 0];
+  const rot: [number, number, number] = rotation ?? [0, 0, 0];
+  const scl: [number, number, number] = scale ?? [1, 1, 1];
+
+  return (
+    <PhysicsWrapper
+      physics={physics}
+      damage={damage}
+      position={pos}
+      rotation={rot}
+      scale={scl}
+    >
+      <mesh
+        castShadow={castShadow ?? false}
+        receiveShadow={receiveShadow ?? false}
+      >
+        <capsuleGeometry args={[radius ?? 0.5, length ?? 1, 16, 32]} />
+        <meshStandardMaterial {...buildMaterialProps(material)} />
+      </mesh>
+    </PhysicsWrapper>
+  );
+}
+
+export function GameKnot({
+  position,
+  rotation,
+  scale,
+  castShadow,
+  receiveShadow,
+  material,
+  physics,
+  damage,
+  radius,
+  tube,
+  tubularSegments,
+  radialSegments,
+  p,
+  q,
+}: GamePrimitiveProps & {
+  radius?: number | null;
+  tube?: number | null;
+  tubularSegments?: number | null;
+  radialSegments?: number | null;
+  p?: number | null;
+  q?: number | null;
+}) {
+  const pos: [number, number, number] = position ?? [0, 0, 0];
+  const rot: [number, number, number] = rotation ?? [0, 0, 0];
+  const scl: [number, number, number] = scale ?? [1, 1, 1];
+
+  return (
+    <PhysicsWrapper
+      physics={physics}
+      damage={damage}
+      position={pos}
+      rotation={rot}
+      scale={scl}
+    >
+      <mesh
+        castShadow={castShadow ?? false}
+        receiveShadow={receiveShadow ?? false}
+      >
+        <torusKnotGeometry
+          args={[
+            radius ?? 1,
+            tube ?? 0.3,
+            tubularSegments ?? 64,
+            radialSegments ?? 8,
+            p ?? 2,
+            q ?? 3,
+          ]}
+        />
+        <meshStandardMaterial {...buildMaterialProps(material)} />
+      </mesh>
+    </PhysicsWrapper>
+  );
+}
+
+function PolyhedronPrimitive({
+  Geometry,
+  ...props
+}: GamePrimitiveProps & {
+  Geometry: "tetrahedron" | "octahedron" | "dodecahedron" | "icosahedron";
+  radius?: number | null;
+}) {
+  const pos: [number, number, number] = props.position ?? [0, 0, 0];
+  const rot: [number, number, number] = props.rotation ?? [0, 0, 0];
+  const scl: [number, number, number] = props.scale ?? [1, 1, 1];
+  const r = props.radius ?? 1;
+
+  const geoElement = {
+    tetrahedron: <tetrahedronGeometry args={[r]} />,
+    octahedron: <octahedronGeometry args={[r]} />,
+    dodecahedron: <dodecahedronGeometry args={[r]} />,
+    icosahedron: <icosahedronGeometry args={[r]} />,
+  }[Geometry];
+
+  return (
+    <PhysicsWrapper
+      physics={props.physics}
+      damage={props.damage}
+      position={pos}
+      rotation={rot}
+      scale={scl}
+    >
+      <mesh
+        castShadow={props.castShadow ?? false}
+        receiveShadow={props.receiveShadow ?? false}
+      >
+        {geoElement}
+        <meshStandardMaterial {...buildMaterialProps(props.material)} />
+      </mesh>
+    </PhysicsWrapper>
+  );
+}
+
+export function GameTetrahedron(
+  props: GamePrimitiveProps & { radius?: number | null },
+) {
+  return <PolyhedronPrimitive {...props} Geometry="tetrahedron" />;
+}
+
+export function GameOctahedron(
+  props: GamePrimitiveProps & { radius?: number | null },
+) {
+  return <PolyhedronPrimitive {...props} Geometry="octahedron" />;
+}
+
+export function GameDodecahedron(
+  props: GamePrimitiveProps & { radius?: number | null },
+) {
+  return <PolyhedronPrimitive {...props} Geometry="dodecahedron" />;
+}
+
+export function GameIcosahedron(
+  props: GamePrimitiveProps & { radius?: number | null },
+) {
+  return <PolyhedronPrimitive {...props} Geometry="icosahedron" />;
+}
+
+export function GameExtrude({
+  position,
+  rotation,
+  scale,
+  castShadow,
+  receiveShadow,
+  material,
+  physics,
+  damage,
+  shapeData,
+  depth,
+}: GamePrimitiveProps & {
+  shapeData?: {
+    points: [number, number][];
+    holes?: [number, number][][];
+  } | null;
+  depth?: number | null;
+}) {
+  const pos: [number, number, number] = position ?? [0, 0, 0];
+  const rot: [number, number, number] = rotation ?? [0, 0, 0];
+  const scl: [number, number, number] = scale ?? [1, 1, 1];
+
+  const shape = new THREE.Shape();
+  const pts = shapeData?.points ?? [
+    [-0.5, -0.5],
+    [0.5, -0.5],
+    [0.5, 0.5],
+    [-0.5, 0.5],
+  ];
+  if (pts.length > 0) {
+    shape.moveTo(pts[0]![0], pts[0]![1]);
+    for (let i = 1; i < pts.length; i++) {
+      shape.lineTo(pts[i]![0], pts[i]![1]);
+    }
+    shape.closePath();
+  }
+  if (shapeData?.holes) {
+    for (const hole of shapeData.holes) {
+      const holePath = new THREE.Path();
+      if (hole.length > 0) {
+        holePath.moveTo(hole[0]![0], hole[0]![1]);
+        for (let i = 1; i < hole.length; i++) {
+          holePath.lineTo(hole[i]![0], hole[i]![1]);
+        }
+        holePath.closePath();
+        shape.holes.push(holePath);
+      }
+    }
+  }
+
+  return (
+    <PhysicsWrapper
+      physics={physics}
+      damage={damage}
+      position={pos}
+      rotation={rot}
+      scale={scl}
+    >
+      <mesh
+        castShadow={castShadow ?? false}
+        receiveShadow={receiveShadow ?? false}
+      >
+        <extrudeGeometry
+          args={[
+            shape as THREE.Shape,
+            { depth: depth ?? 1, bevelEnabled: false },
+          ]}
+        />
+        <meshStandardMaterial {...buildMaterialProps(material)} />
+      </mesh>
+    </PhysicsWrapper>
+  );
+}
+
+export function GameTube({
+  position,
+  rotation,
+  scale,
+  castShadow,
+  receiveShadow,
+  material,
+  physics,
+  damage,
+  radius,
+  tubularSegments,
+  radialSegments,
+}: GamePrimitiveProps & {
+  radius?: number | null;
+  tubularSegments?: number | null;
+  radialSegments?: number | null;
+}) {
+  const pos: [number, number, number] = position ?? [0, 0, 0];
+  const rot: [number, number, number] = rotation ?? [0, 0, 0];
+  const scl: [number, number, number] = scale ?? [1, 1, 1];
+
+  const curve = new THREE.CatmullRomCurve3([
+    new THREE.Vector3(-1, 0, 0),
+    new THREE.Vector3(-0.5, 0.5, 0),
+    new THREE.Vector3(0.5, -0.5, 0),
+    new THREE.Vector3(1, 0, 0),
+  ]);
+
+  return (
+    <PhysicsWrapper
+      physics={physics}
+      damage={damage}
+      position={pos}
+      rotation={rot}
+      scale={scl}
+    >
+      <mesh
+        castShadow={castShadow ?? false}
+        receiveShadow={receiveShadow ?? false}
+      >
+        <tubeGeometry
+          args={[
+            curve as THREE.Curve<THREE.Vector3>,
+            tubularSegments ?? 64,
+            radius ?? 0.1,
+            radialSegments ?? 8,
+            false,
+          ]}
+        />
+        <meshStandardMaterial {...buildMaterialProps(material)} />
+      </mesh>
+    </PhysicsWrapper>
+  );
+}
+
+export function GameShape({
+  position,
+  rotation,
+  scale,
+  castShadow,
+  receiveShadow,
+  material,
+  physics,
+  damage,
+  shapeData,
+}: GamePrimitiveProps & {
+  shapeData?: {
+    points: [number, number][];
+    holes?: [number, number][][];
+  } | null;
+}) {
+  const pos: [number, number, number] = position ?? [0, 0, 0];
+  const rot: [number, number, number] = rotation ?? [0, 0, 0];
+  const scl: [number, number, number] = scale ?? [1, 1, 1];
+
+  const shape = new THREE.Shape();
+  const pts = shapeData?.points ?? [
+    [-0.5, -0.5],
+    [0.5, -0.5],
+    [0, 0.5],
+  ];
+  if (pts.length > 0) {
+    shape.moveTo(pts[0]![0], pts[0]![1]);
+    for (let i = 1; i < pts.length; i++) {
+      shape.lineTo(pts[i]![0], pts[i]![1]);
+    }
+    shape.closePath();
+  }
+  if (shapeData?.holes) {
+    for (const hole of shapeData.holes) {
+      const holePath = new THREE.Path();
+      if (hole.length > 0) {
+        holePath.moveTo(hole[0]![0], hole[0]![1]);
+        for (let i = 1; i < hole.length; i++) {
+          holePath.lineTo(hole[i]![0], hole[i]![1]);
+        }
+        holePath.closePath();
+        shape.holes.push(holePath);
+      }
+    }
+  }
+
+  return (
+    <PhysicsWrapper
+      physics={physics}
+      damage={damage}
+      position={pos}
+      rotation={rot}
+      scale={scl}
+    >
+      <mesh
+        castShadow={castShadow ?? false}
+        receiveShadow={receiveShadow ?? false}
+      >
+        <shapeGeometry args={[shape as THREE.Shape]} />
+        <meshStandardMaterial
+          {...buildMaterialProps(material)}
+          side={THREE.DoubleSide}
+        />
+      </mesh>
+    </PhysicsWrapper>
+  );
+}
+
+export function GameMesh({
+  position,
+  rotation,
+  scale,
+  castShadow,
+  receiveShadow,
+  material,
+  physics,
+  damage,
+  meshData,
+}: GamePrimitiveProps & {
+  meshData?: {
+    vertices: number[];
+    indices: number[];
+    normals?: number[];
+    uvs?: number[];
+  } | null;
+}) {
+  const pos: [number, number, number] = position ?? [0, 0, 0];
+  const rot: [number, number, number] = rotation ?? [0, 0, 0];
+  const scl: [number, number, number] = scale ?? [1, 1, 1];
+
+  const meshRef = useRef<THREE.Mesh>(null);
+
+  useEffect(() => {
+    if (!meshRef.current || !meshData) return;
+    const geometry = new THREE.BufferGeometry();
+    geometry.setAttribute(
+      "position",
+      new THREE.Float32BufferAttribute(meshData.vertices, 3),
+    );
+    if (meshData.indices.length > 0) {
+      geometry.setIndex(meshData.indices);
+    }
+    if (meshData.normals) {
+      geometry.setAttribute(
+        "normal",
+        new THREE.Float32BufferAttribute(meshData.normals, 3),
+      );
+    } else {
+      geometry.computeVertexNormals();
+    }
+    if (meshData.uvs) {
+      geometry.setAttribute(
+        "uv",
+        new THREE.Float32BufferAttribute(meshData.uvs, 2),
+      );
+    }
+    meshRef.current.geometry = geometry;
+  }, [meshData]);
+
+  return (
+    <PhysicsWrapper
+      physics={physics}
+      damage={damage}
+      position={pos}
+      rotation={rot}
+      scale={scl}
+    >
+      <mesh
+        ref={meshRef}
+        castShadow={castShadow ?? false}
+        receiveShadow={receiveShadow ?? false}
+      >
+        <boxGeometry args={[1, 1, 1]} />
+        <meshStandardMaterial {...buildMaterialProps(material)} />
+      </mesh>
+    </PhysicsWrapper>
+  );
+}

+ 52 - 0
examples/game-engine/components/game/ground-plane.tsx

@@ -0,0 +1,52 @@
+"use client";
+
+import * as THREE from "three";
+import { RigidBody, CuboidCollider } from "@react-three/rapier";
+import { useEditorStore } from "@/lib/store";
+
+interface GroundPlaneProps {
+  position?: [number, number, number] | null;
+  rotation?: [number, number, number] | null;
+  scale?: [number, number, number] | null;
+  material?: {
+    color?: string | null;
+    metalness?: number | null;
+    roughness?: number | null;
+  } | null;
+  size?: number | null;
+}
+
+export function GroundPlane({ position, material, size }: GroundPlaneProps) {
+  const isPlaying = useEditorStore((s) => s.isPlaying);
+  const s = size ?? 5000;
+  const pos: [number, number, number] = position ?? [0, -0.1, 0];
+
+  const plane = (
+    <mesh rotation={[-Math.PI / 2, 0, 0]} position={pos} receiveShadow>
+      <planeGeometry args={[s, s]} />
+      <meshStandardMaterial
+        color={material?.color ?? "#4CAF50"}
+        metalness={material?.metalness ?? 0}
+        roughness={material?.roughness ?? 0.9}
+        side={THREE.DoubleSide}
+      />
+    </mesh>
+  );
+
+  if (!isPlaying) return plane;
+
+  return (
+    <RigidBody type="fixed" position={pos} colliders={false}>
+      <mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
+        <planeGeometry args={[s, s]} />
+        <meshStandardMaterial
+          color={material?.color ?? "#4CAF50"}
+          metalness={material?.metalness ?? 0}
+          roughness={material?.roughness ?? 0.9}
+          side={THREE.DoubleSide}
+        />
+      </mesh>
+      <CuboidCollider args={[s / 2, 0.01, s / 2]} position={[0, 0, 0]} />
+    </RigidBody>
+  );
+}

+ 92 - 0
examples/game-engine/components/game/media-plane.tsx

@@ -0,0 +1,92 @@
+"use client";
+
+import { useRef, useEffect, useState } from "react";
+import * as THREE from "three";
+
+interface MediaPlaneProps {
+  position?: [number, number, number] | null;
+  rotation?: [number, number, number] | null;
+  scale?: [number, number, number] | null;
+  castShadow?: boolean | null;
+  receiveShadow?: boolean | null;
+  url: string;
+  mediaType: "image" | "video";
+  loop?: boolean | null;
+  autoplay?: boolean | null;
+  muted?: boolean | null;
+  width?: number | null;
+  height?: number | null;
+  objectId?: string | null;
+}
+
+export function MediaPlane({
+  position,
+  rotation,
+  scale,
+  url,
+  mediaType,
+  loop,
+  autoplay,
+  muted,
+  width,
+  height,
+}: MediaPlaneProps) {
+  const meshRef = useRef<THREE.Mesh>(null);
+  const [texture, setTexture] = useState<THREE.Texture | null>(null);
+
+  useEffect(() => {
+    if (!url) return;
+
+    if (mediaType === "video") {
+      const video = document.createElement("video");
+      video.src = url;
+      video.crossOrigin = "anonymous";
+      video.loop = loop ?? false;
+      video.muted = muted ?? true;
+      if (autoplay !== false) video.play();
+
+      const tex = new THREE.VideoTexture(video);
+      tex.colorSpace = THREE.SRGBColorSpace;
+      setTexture(tex);
+
+      return () => {
+        video.pause();
+        video.src = "";
+        tex.dispose();
+      };
+    } else {
+      const loader = new THREE.TextureLoader();
+      loader.load(url, (tex) => {
+        tex.colorSpace = THREE.SRGBColorSpace;
+        setTexture(tex);
+      });
+    }
+  }, [url, mediaType, loop, autoplay, muted]);
+
+  const pos: [number, number, number] = position ?? [0, 0, 0];
+  const rot: [number, number, number] = rotation ?? [0, 0, 0];
+  const scl: [number, number, number] = scale ?? [1, 1, 1];
+  const w = width ?? 2;
+  const h = height ?? w * 0.75;
+
+  return (
+    <mesh
+      ref={meshRef}
+      position={pos}
+      rotation={rot}
+      scale={scl}
+      castShadow
+      receiveShadow
+    >
+      <planeGeometry args={[w, h]} />
+      {texture ? (
+        <meshBasicMaterial
+          map={texture as THREE.Texture}
+          side={THREE.DoubleSide}
+        />
+      ) : (
+        <meshStandardMaterial color="#333" side={THREE.DoubleSide} />
+      )}
+    </mesh>
+  );
+}

+ 158 - 0
examples/game-engine/components/game/model-wrapper.tsx

@@ -0,0 +1,158 @@
+"use client";
+
+import { useRef, useEffect, Suspense } from "react";
+import { useGLTF } from "@react-three/drei";
+import {
+  RigidBody,
+  CuboidCollider,
+  BallCollider,
+  CapsuleCollider,
+} from "@react-three/rapier";
+import type * as THREE from "three";
+import { useEditorStore } from "@/lib/store";
+
+interface PhysicsProps {
+  mass?: number | null;
+  isStatic?: boolean | null;
+  restitution?: number | null;
+  friction?: number | null;
+  colliderType?: string | null;
+}
+
+interface DamageProps {
+  amount?: number | null;
+  enabled?: boolean | null;
+}
+
+interface GameModelProps {
+  position?: [number, number, number] | null;
+  rotation?: [number, number, number] | null;
+  scale?: [number, number, number] | null;
+  castShadow?: boolean | null;
+  receiveShadow?: boolean | null;
+  url: string;
+  physics?: PhysicsProps | null;
+  damage?: DamageProps | null;
+  objectId?: string | null;
+}
+
+function ModelInner({
+  url,
+  userData,
+}: {
+  url: string;
+  userData: Record<string, unknown>;
+}) {
+  const groupRef = useRef<THREE.Group>(null);
+  const { scene } = useGLTF(url);
+  const setIsLoading = useEditorStore((s) => s.setIsLoading);
+
+  const model = scene.clone();
+
+  useEffect(() => {
+    model.traverse((node) => {
+      if ((node as THREE.Mesh).isMesh) {
+        node.castShadow = true;
+        node.receiveShadow = true;
+      }
+    });
+  }, [model]);
+
+  useEffect(() => {
+    if (groupRef.current) {
+      Object.assign(groupRef.current.userData, userData);
+    }
+  }, [userData]);
+
+  useEffect(() => {
+    const timer = setTimeout(() => setIsLoading(false), 100);
+    return () => clearTimeout(timer);
+  }, [setIsLoading]);
+
+  return (
+    <group ref={groupRef}>
+      <primitive object={model} />
+    </group>
+  );
+}
+
+export function GameModel({
+  position,
+  rotation,
+  scale,
+  url,
+  physics,
+  damage,
+  objectId,
+}: GameModelProps) {
+  const isPlaying = useEditorStore((s) => s.isPlaying);
+  const takeDamage = useEditorStore((s) => s.takeDamage);
+  const setIsLoading = useEditorStore((s) => s.setIsLoading);
+
+  const pos: [number, number, number] = position ?? [0, 0, 0];
+  const rot: [number, number, number] = rotation ?? [0, 0, 0];
+  const scl: [number, number, number] = scale ?? [1, 1, 1];
+
+  const hasPhysics =
+    isPlaying &&
+    physics &&
+    physics.colliderType &&
+    physics.colliderType !== "none";
+  const hasDamage = damage?.enabled && (damage.amount ?? 0) > 0;
+
+  useEffect(() => {
+    if (url) setIsLoading(true);
+  }, [url, setIsLoading]);
+
+  if (!url) {
+    return (
+      <group position={pos} rotation={rot} scale={scl}>
+        <mesh castShadow receiveShadow>
+          <boxGeometry args={[1, 1, 1]} />
+          <meshStandardMaterial color="#888888" wireframe />
+        </mesh>
+      </group>
+    );
+  }
+
+  const inner = (
+    <Suspense fallback={null}>
+      <ModelInner url={url} userData={{ id: objectId, objectId }} />
+    </Suspense>
+  );
+
+  if (!hasPhysics) {
+    return (
+      <group position={pos} rotation={rot} scale={scl}>
+        {inner}
+      </group>
+    );
+  }
+
+  const collider =
+    physics!.colliderType === "ball" ? (
+      <BallCollider args={[Math.max(scl[0], scl[1], scl[2]) * 0.5]} />
+    ) : physics!.colliderType === "capsule" ? (
+      <CapsuleCollider args={[scl[1] * 0.25, Math.max(scl[0], scl[2]) * 0.5]} />
+    ) : (
+      <CuboidCollider args={[scl[0] * 0.5, scl[1] * 0.5, scl[2] * 0.5]} />
+    );
+
+  return (
+    <RigidBody
+      type={physics!.isStatic ? "fixed" : "dynamic"}
+      position={pos}
+      rotation={rot}
+      mass={physics!.mass ?? 1}
+      restitution={physics!.restitution ?? 0.2}
+      friction={physics!.friction ?? 0.5}
+      colliders={false}
+      onCollisionEnter={
+        hasDamage ? () => takeDamage(damage!.amount!) : undefined
+      }
+    >
+      <group scale={scl}>{inner}</group>
+      {collider}
+    </RigidBody>
+  );
+}

+ 529 - 0
examples/game-engine/components/game/player.tsx

@@ -0,0 +1,529 @@
+"use client";
+
+import { useEffect, useRef, useState, Suspense } from "react";
+import { useFrame, useThree } from "@react-three/fiber";
+import { PointerLockControls, useGLTF } from "@react-three/drei";
+import { RigidBody, CapsuleCollider, useRapier } from "@react-three/rapier";
+import type { RapierRigidBody } from "@react-three/rapier";
+import * as THREE from "three";
+import { useEditorStore } from "@/lib/store";
+import { useIsMobile } from "@/lib/use-mobile";
+import { touchMoveState } from "@/lib/touch-state";
+import { resetBoneScales } from "@/lib/bone-utils";
+
+interface PlayerProps {
+  position?: [number, number, number] | null;
+  rotation?: [number, number, number] | null;
+  scale?: [number, number, number] | null;
+  objectId?: string | null;
+  isPlayer?: boolean | null;
+}
+
+export function Player({ position }: PlayerProps) {
+  const isPlaying = useEditorStore((s) => s.isPlaying);
+  const viewMode = useEditorStore((s) => s.viewMode);
+  const health = useEditorStore((s) => s.health);
+  const isPromptOpen = useEditorStore((s) => s.isPromptOpen);
+
+  if (!isPlaying) {
+    const pos = position ?? [0, 0, 0];
+    return (
+      <mesh position={pos as [number, number, number]}>
+        <capsuleGeometry args={[0.3, 0.8, 8, 16]} />
+        <meshStandardMaterial color="#3498db" transparent opacity={0.5} />
+      </mesh>
+    );
+  }
+
+  if (viewMode === "first-person") {
+    return (
+      <FirstPersonController
+        initialPosition={position ?? [0, 0, 0]}
+        health={health}
+        isPromptOpen={isPromptOpen}
+      />
+    );
+  }
+
+  return (
+    <ThirdPersonController
+      initialPosition={position ?? [0, 0, 0]}
+      health={health}
+      isPromptOpen={isPromptOpen}
+    />
+  );
+}
+
+function FirstPersonController({
+  initialPosition,
+  health,
+  isPromptOpen,
+}: {
+  initialPosition: [number, number, number];
+  health: number;
+  isPromptOpen: boolean;
+}) {
+  const { camera } = useThree();
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const controlsRef = useRef<any>(null);
+  const playerRef = useRef<RapierRigidBody>(null);
+  const isMobile = useIsMobile();
+
+  const moveState = useRef({
+    forward: false,
+    backward: false,
+    left: false,
+    right: false,
+    jump: false,
+    sprint: false,
+  });
+
+  const { rapier, world } = useRapier();
+  const playerDirection = useRef(new THREE.Vector3());
+  const isOnGround = useRef(true);
+  const jumpCooldown = useRef(0);
+  const touchYaw = useRef(0);
+  const touchPitch = useRef(0);
+
+  const speed = 5;
+  const jumpForce = 5;
+  const groundCastDist = 0.85;
+  const isGameOver = health <= 0;
+
+  useEffect(() => {
+    if (isMobile) {
+      camera.rotation.order = "YXZ";
+      camera.rotation.set(0, 0, 0);
+      touchYaw.current = 0;
+      touchPitch.current = 0;
+    }
+  }, [isMobile, camera]);
+
+  useEffect(() => {
+    if (isMobile) return;
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (isPromptOpen || isGameOver) return;
+      switch (e.code) {
+        case "KeyW":
+        case "ArrowUp":
+          moveState.current.forward = true;
+          break;
+        case "KeyS":
+        case "ArrowDown":
+          moveState.current.backward = true;
+          break;
+        case "KeyA":
+        case "ArrowLeft":
+          moveState.current.left = true;
+          break;
+        case "KeyD":
+        case "ArrowRight":
+          moveState.current.right = true;
+          break;
+        case "Space":
+          moveState.current.jump = true;
+          break;
+        case "ShiftLeft":
+        case "ShiftRight":
+          moveState.current.sprint = true;
+          break;
+      }
+    };
+    const handleKeyUp = (e: KeyboardEvent) => {
+      switch (e.code) {
+        case "KeyW":
+        case "ArrowUp":
+          moveState.current.forward = false;
+          break;
+        case "KeyS":
+        case "ArrowDown":
+          moveState.current.backward = false;
+          break;
+        case "KeyA":
+        case "ArrowLeft":
+          moveState.current.left = false;
+          break;
+        case "KeyD":
+        case "ArrowRight":
+          moveState.current.right = false;
+          break;
+        case "Space":
+          moveState.current.jump = false;
+          break;
+        case "ShiftLeft":
+        case "ShiftRight":
+          moveState.current.sprint = false;
+          break;
+      }
+    };
+    window.addEventListener("keydown", handleKeyDown);
+    window.addEventListener("keyup", handleKeyUp);
+    return () => {
+      window.removeEventListener("keydown", handleKeyDown);
+      window.removeEventListener("keyup", handleKeyUp);
+    };
+  }, [isPromptOpen, isGameOver, isMobile]);
+
+  useFrame((_, delta) => {
+    if (!playerRef.current || isGameOver) return;
+
+    const body = playerRef.current;
+    const vel = body.linvel();
+
+    let forward: boolean;
+    let backward: boolean;
+    let left: boolean;
+    let right: boolean;
+    let jump: boolean;
+    let sprint: boolean;
+
+    if (isMobile) {
+      forward = touchMoveState.forward > 0.15;
+      backward = touchMoveState.forward < -0.15;
+      left = touchMoveState.right < -0.15;
+      right = touchMoveState.right > 0.15;
+      jump = touchMoveState.jump;
+      sprint = touchMoveState.sprint;
+
+      if (touchMoveState.lookDeltaX !== 0 || touchMoveState.lookDeltaY !== 0) {
+        touchYaw.current -= touchMoveState.lookDeltaX;
+        touchPitch.current -= touchMoveState.lookDeltaY;
+        touchPitch.current = Math.max(
+          -Math.PI / 2 + 0.01,
+          Math.min(Math.PI / 2 - 0.01, touchPitch.current),
+        );
+        camera.rotation.order = "YXZ";
+        camera.rotation.y = touchYaw.current;
+        camera.rotation.x = touchPitch.current;
+        touchMoveState.lookDeltaX = 0;
+        touchMoveState.lookDeltaY = 0;
+      }
+    } else {
+      ({ forward, backward, left, right, jump, sprint } = moveState.current);
+    }
+
+    playerDirection.current.set(0, 0, 0);
+
+    const frontVector = new THREE.Vector3(
+      0,
+      0,
+      (backward ? 1 : 0) - (forward ? 1 : 0),
+    );
+    const sideVector = new THREE.Vector3(
+      (right ? 1 : 0) - (left ? 1 : 0),
+      0,
+      0,
+    );
+    playerDirection.current
+      .addVectors(frontVector, sideVector)
+      .normalize()
+      .multiplyScalar(speed * (sprint ? 1.8 : 1))
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
+      .applyEuler(camera.rotation as any);
+
+    body.setLinvel(
+      { x: playerDirection.current.x, y: vel.y, z: playerDirection.current.z },
+      true,
+    );
+
+    if (jump && isOnGround.current && jumpCooldown.current <= 0) {
+      body.setLinvel({ x: vel.x, y: jumpForce, z: vel.z }, true);
+      isOnGround.current = false;
+      jumpCooldown.current = 0.3;
+    }
+
+    if (jumpCooldown.current > 0) jumpCooldown.current -= delta;
+
+    const pos = body.translation();
+    const ray = new rapier.Ray(
+      { x: pos.x, y: pos.y, z: pos.z },
+      { x: 0, y: -1, z: 0 },
+    );
+    const hit = world.castRay(ray, groundCastDist, false);
+    isOnGround.current = hit !== null;
+
+    camera.position.set(pos.x, pos.y + 0.85, pos.z);
+  });
+
+  return (
+    <>
+      {!isMobile && <PointerLockControls ref={controlsRef} />}
+      <RigidBody
+        ref={playerRef}
+        position={[
+          initialPosition[0],
+          initialPosition[1] + 2,
+          initialPosition[2],
+        ]}
+        enabledRotations={[false, false, false]}
+        mass={1}
+        linearDamping={0.5}
+        type="dynamic"
+        colliders={false}
+        lockRotations
+      >
+        <CapsuleCollider args={[0.35, 0.3]} />
+      </RigidBody>
+    </>
+  );
+}
+
+function AnimatedCharacterModelInner({
+  characterRef,
+  isMoving,
+}: {
+  characterRef: React.RefObject<THREE.Group | null>;
+  isMoving: boolean;
+}) {
+  const characterGltf = useGLTF("/models/bot.glb");
+  const walkGltf = useGLTF("/models/walking.glb");
+  const idleGltf = useGLTF("/models/idle.glb");
+
+  const mixerRef = useRef<THREE.AnimationMixer | null>(null);
+  const actionsRef = useRef<Record<string, THREE.AnimationAction>>({});
+  const [ready, setReady] = useState(false);
+
+  useEffect(() => {
+    if (!characterRef.current || !characterGltf || !walkGltf || !idleGltf)
+      return;
+
+    try {
+      characterGltf.scene.scale.set(1, 1, 1);
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
+      resetBoneScales(characterGltf.scene as any);
+
+      characterRef.current.clear();
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch between drei and three
+      characterRef.current.add(characterGltf.scene.clone() as any);
+
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
+      const mixer = new THREE.AnimationMixer(characterRef.current as any);
+      mixerRef.current = mixer;
+
+      if (idleGltf.animations[0] && walkGltf.animations[0]) {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
+        const idleAction = mixer.clipAction(idleGltf.animations[0] as any);
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
+        const walkAction = mixer.clipAction(walkGltf.animations[0] as any);
+        actionsRef.current = { idle: idleAction, walk: walkAction };
+        idleAction.play();
+        setReady(true);
+      }
+    } catch {
+      // Model loading failed
+    }
+  }, [characterRef, characterGltf, walkGltf, idleGltf]);
+
+  useEffect(() => {
+    if (!ready) return;
+    const { idle, walk } = actionsRef.current;
+    if (!idle || !walk) return;
+
+    if (isMoving) {
+      idle.fadeOut(0.2);
+      walk.reset().fadeIn(0.2).play();
+    } else {
+      walk.fadeOut(0.2);
+      idle.reset().fadeIn(0.2).play();
+    }
+  }, [isMoving, ready]);
+
+  useFrame((_, delta) => {
+    mixerRef.current?.update(delta);
+  });
+
+  return null;
+}
+
+function ThirdPersonController({
+  initialPosition,
+  health,
+  isPromptOpen,
+}: {
+  initialPosition: [number, number, number];
+  health: number;
+  isPromptOpen: boolean;
+}) {
+  const { camera } = useThree();
+  const playerRef = useRef<RapierRigidBody>(null);
+  const characterRef = useRef<THREE.Group>(null);
+  const cameraAngle = useRef(0);
+  const [isMoving, setIsMoving] = useState(false);
+  const isMobile = useIsMobile();
+
+  const moveState = useRef({
+    forward: false,
+    backward: false,
+    left: false,
+    right: false,
+    sprint: false,
+  });
+
+  const speed = 5;
+  const isGameOver = health <= 0;
+
+  useEffect(() => {
+    if (isMobile) {
+      cameraAngle.current = 0;
+    }
+  }, [isMobile]);
+
+  useEffect(() => {
+    if (isMobile) return;
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (isPromptOpen || isGameOver) return;
+      switch (e.code) {
+        case "KeyW":
+        case "ArrowUp":
+          moveState.current.forward = true;
+          break;
+        case "KeyS":
+        case "ArrowDown":
+          moveState.current.backward = true;
+          break;
+        case "KeyA":
+        case "ArrowLeft":
+          moveState.current.left = true;
+          break;
+        case "KeyD":
+        case "ArrowRight":
+          moveState.current.right = true;
+          break;
+        case "ShiftLeft":
+        case "ShiftRight":
+          moveState.current.sprint = true;
+          break;
+      }
+    };
+    const handleKeyUp = (e: KeyboardEvent) => {
+      switch (e.code) {
+        case "KeyW":
+        case "ArrowUp":
+          moveState.current.forward = false;
+          break;
+        case "KeyS":
+        case "ArrowDown":
+          moveState.current.backward = false;
+          break;
+        case "KeyA":
+        case "ArrowLeft":
+          moveState.current.left = false;
+          break;
+        case "KeyD":
+        case "ArrowRight":
+          moveState.current.right = false;
+          break;
+        case "ShiftLeft":
+        case "ShiftRight":
+          moveState.current.sprint = false;
+          break;
+      }
+    };
+    const handleMouseMove = (e: MouseEvent) => {
+      cameraAngle.current -= e.movementX * 0.003;
+    };
+    window.addEventListener("keydown", handleKeyDown);
+    window.addEventListener("keyup", handleKeyUp);
+    window.addEventListener("mousemove", handleMouseMove);
+    return () => {
+      window.removeEventListener("keydown", handleKeyDown);
+      window.removeEventListener("keyup", handleKeyUp);
+      window.removeEventListener("mousemove", handleMouseMove);
+    };
+  }, [isPromptOpen, isGameOver, isMobile]);
+
+  useFrame(() => {
+    if (!playerRef.current || isGameOver) return;
+
+    const body = playerRef.current;
+    const vel = body.linvel();
+
+    let forward: boolean;
+    let backward: boolean;
+    let left: boolean;
+    let right: boolean;
+    let sprint: boolean;
+
+    if (isMobile) {
+      forward = touchMoveState.forward > 0.15;
+      backward = touchMoveState.forward < -0.15;
+      left = touchMoveState.right < -0.15;
+      right = touchMoveState.right > 0.15;
+      sprint = touchMoveState.sprint;
+
+      if (touchMoveState.lookDeltaX !== 0) {
+        cameraAngle.current -= touchMoveState.lookDeltaX;
+        touchMoveState.lookDeltaX = 0;
+        touchMoveState.lookDeltaY = 0;
+      }
+    } else {
+      ({ forward, backward, left, right, sprint } = moveState.current);
+    }
+
+    const dir = new THREE.Vector3(0, 0, 0);
+    if (forward) dir.z -= 1;
+    if (backward) dir.z += 1;
+    if (left) dir.x -= 1;
+    if (right) dir.x += 1;
+    dir.normalize().multiplyScalar(speed * (sprint ? 1.8 : 1));
+    dir.applyAxisAngle(new THREE.Vector3(0, 1, 0), cameraAngle.current);
+
+    body.setLinvel({ x: dir.x, y: vel.y, z: dir.z }, true);
+
+    const moving = dir.length() > 0.1;
+    setIsMoving(moving);
+
+    const pos = body.translation();
+
+    if (characterRef.current) {
+      characterRef.current.position.set(pos.x, pos.y, pos.z);
+      if (moving) {
+        const targetAngle = Math.atan2(dir.x, dir.z);
+        characterRef.current.rotation.y = targetAngle;
+      }
+    }
+
+    const camDist = 6;
+    const camHeight = 3;
+    camera.position.set(
+      pos.x + Math.sin(cameraAngle.current) * camDist,
+      pos.y + camHeight,
+      pos.z + Math.cos(cameraAngle.current) * camDist,
+    );
+    camera.lookAt(pos.x, pos.y + 1, pos.z);
+  });
+
+  return (
+    <>
+      <RigidBody
+        ref={playerRef}
+        position={[
+          initialPosition[0],
+          initialPosition[1] + 2,
+          initialPosition[2],
+        ]}
+        enabledRotations={[false, false, false]}
+        mass={1}
+        linearDamping={0.5}
+        type="dynamic"
+        colliders={false}
+        lockRotations
+      >
+        <CapsuleCollider args={[0.35, 0.3]} />
+      </RigidBody>
+      <group ref={characterRef} position={initialPosition}>
+        <Suspense fallback={null}>
+          <AnimatedCharacterModelInner
+            characterRef={characterRef}
+            isMoving={isMoving}
+          />
+        </Suspense>
+        {/* Fallback capsule mesh visible until models load */}
+        <mesh castShadow>
+          <capsuleGeometry args={[0.3, 0.8, 8, 16]} />
+          <meshStandardMaterial color="#3498db" />
+        </mesh>
+      </group>
+    </>
+  );
+}

+ 80 - 0
examples/game-engine/components/game/sound-emitter.tsx

@@ -0,0 +1,80 @@
+"use client";
+
+import { useRef, useEffect } from "react";
+import { useThree } from "@react-three/fiber";
+import * as THREE from "three";
+import { useEditorStore } from "@/lib/store";
+
+interface SoundEmitterProps {
+  position?: [number, number, number] | null;
+  rotation?: [number, number, number] | null;
+  scale?: [number, number, number] | null;
+  url: string;
+  loop?: boolean | null;
+  volume?: number | null;
+  positional?: boolean | null;
+  distance?: number | null;
+  objectId?: string | null;
+}
+
+export function SoundEmitter({
+  position,
+  url,
+  loop,
+  volume,
+  positional,
+  distance,
+}: SoundEmitterProps) {
+  const isPlaying = useEditorStore((s) => s.isPlaying);
+  const { camera } = useThree();
+  const listenerRef = useRef<THREE.AudioListener | null>(null);
+  const soundRef = useRef<THREE.PositionalAudio | THREE.Audio | null>(null);
+  const pos: [number, number, number] = position ?? [0, 0, 0];
+
+  useEffect(() => {
+    if (!isPlaying || !url) return;
+
+    const listener = new THREE.AudioListener();
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    camera.add(listener as any);
+    listenerRef.current = listener;
+
+    const loader = new THREE.AudioLoader();
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    let audio: any;
+
+    if (positional !== false) {
+      audio = new THREE.PositionalAudio(listener);
+      audio.setRefDistance(distance ?? 10);
+    } else {
+      audio = new THREE.Audio(listener);
+    }
+
+    loader.load(url, (buffer) => {
+      audio.setBuffer(buffer);
+      audio.setLoop(loop ?? false);
+      audio.setVolume(volume ?? 1);
+      audio.play();
+    });
+
+    soundRef.current = audio;
+
+    return () => {
+      if (soundRef.current?.isPlaying) soundRef.current.stop();
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      camera.remove(listener as any);
+    };
+  }, [isPlaying, url, loop, volume, positional, distance, camera]);
+
+  // Edit mode indicator
+  if (!isPlaying) {
+    return (
+      <mesh position={pos}>
+        <sphereGeometry args={[0.2, 8, 8]} />
+        <meshStandardMaterial color="#9b59b6" wireframe />
+      </mesh>
+    );
+  }
+
+  return <group position={pos} />;
+}

+ 228 - 0
examples/game-engine/components/hud/character-dialog.tsx

@@ -0,0 +1,228 @@
+"use client";
+
+import { useState, useEffect, useCallback, useRef } from "react";
+import { X, Volume2, VolumeX } from "lucide-react";
+import { useIsMobile } from "@/lib/use-mobile";
+
+interface DialogMessage {
+  text: string;
+  audioUrl?: string;
+}
+
+export function CharacterDialog() {
+  const [isOpen, setIsOpen] = useState(false);
+  const [messages, setMessages] = useState<DialogMessage[]>([]);
+  const [currentIndex, setCurrentIndex] = useState(0);
+  const [characterRole, setCharacterRole] = useState("");
+  const [isLoading, setIsLoading] = useState(false);
+  const [isMuted, setIsMuted] = useState(false);
+  const audioRef = useRef<HTMLAudioElement | null>(null);
+  const isMobile = useIsMobile();
+
+  useEffect(() => {
+    const audio = new Audio();
+    audioRef.current = audio;
+    return () => {
+      audio.pause();
+      audio.src = "";
+    };
+  }, []);
+
+  useEffect(() => {
+    const handleInteract = (e: Event) => {
+      const detail = (e as CustomEvent).detail as {
+        id?: string;
+        role?: string;
+        messages?: DialogMessage[];
+      };
+
+      setCharacterRole(detail?.role || "NPC");
+      setCurrentIndex(0);
+      setIsOpen(true);
+      window.dispatchEvent(new CustomEvent("dialog-open"));
+
+      if (detail?.messages && detail.messages.length > 0) {
+        setMessages(detail.messages);
+        setIsLoading(false);
+      } else {
+        setIsLoading(true);
+        fetchCharacterResponses(detail?.role || "villager").then(
+          (responses) => {
+            setMessages(responses);
+            setIsLoading(false);
+          },
+        );
+      }
+    };
+
+    window.addEventListener("character-interact", handleInteract);
+    return () =>
+      window.removeEventListener("character-interact", handleInteract);
+  }, []);
+
+  useEffect(() => {
+    if (!isLoading && messages.length > 0 && !isMuted && audioRef.current) {
+      const current = messages[currentIndex];
+      if (current?.audioUrl) {
+        audioRef.current.src = current.audioUrl;
+        audioRef.current.volume = 1.0;
+        audioRef.current.play().catch(() => {});
+      }
+    }
+  }, [currentIndex, messages, isLoading, isMuted]);
+
+  const handleClose = useCallback(() => {
+    setIsOpen(false);
+    setMessages([]);
+    setCurrentIndex(0);
+    if (audioRef.current) audioRef.current.pause();
+    window.dispatchEvent(new CustomEvent("dialog-close"));
+  }, []);
+
+  const handleNext = useCallback(() => {
+    if (audioRef.current) audioRef.current.pause();
+    if (currentIndex < messages.length - 1) {
+      setCurrentIndex((i) => i + 1);
+    } else {
+      handleClose();
+    }
+  }, [currentIndex, messages.length, handleClose]);
+
+  useEffect(() => {
+    if (!isOpen) return;
+    const handleKey = (e: KeyboardEvent) => {
+      if (e.key === "Enter") {
+        handleNext();
+      } else if (e.key === "Escape") {
+        handleClose();
+      }
+    };
+    window.addEventListener("keydown", handleKey);
+    return () => window.removeEventListener("keydown", handleKey);
+  }, [isOpen, handleNext, handleClose]);
+
+  const toggleMute = () => {
+    setIsMuted(!isMuted);
+    if (audioRef.current) {
+      if (!isMuted) {
+        audioRef.current.pause();
+      } else if (messages[currentIndex]?.audioUrl) {
+        audioRef.current.src = messages[currentIndex].audioUrl!;
+        audioRef.current.volume = 1.0;
+        audioRef.current.play().catch(() => {});
+      }
+    }
+  };
+
+  if (!isOpen) return null;
+
+  if (isLoading) {
+    return (
+      <div className="absolute bottom-20 left-1/2 -translate-x-1/2 z-20 w-[480px] max-w-[90vw]">
+        <div className="bg-[#111]/95 border border-[#333] rounded-lg p-4 backdrop-blur-sm">
+          <div className="flex items-start justify-between mb-2">
+            <span className="text-[10px] text-[#666] uppercase tracking-wider">
+              {characterRole}
+            </span>
+            <button
+              onClick={handleClose}
+              className="p-1.5 text-[#666] hover:text-white active:text-white"
+            >
+              <X size={14} />
+            </button>
+          </div>
+          <div className="animate-pulse h-4 bg-[#222] rounded w-3/4 mb-2" />
+          <div className="animate-pulse h-4 bg-[#222] rounded w-1/2" />
+          <p className="text-[9px] text-[#555] mt-3">Loading...</p>
+        </div>
+      </div>
+    );
+  }
+
+  if (messages.length === 0) return null;
+
+  const current = messages[currentIndex];
+  const isLast = currentIndex >= messages.length - 1;
+
+  return (
+    <div className="absolute bottom-20 left-1/2 -translate-x-1/2 z-20 w-[480px] max-w-[90vw]">
+      <div className="bg-[#111]/95 border border-[#333] rounded-lg p-4 backdrop-blur-sm">
+        <div className="flex items-start justify-between mb-2">
+          <span className="text-[10px] text-[#666] uppercase tracking-wider">
+            {characterRole || "NPC"}
+          </span>
+          <div className="flex items-center gap-2">
+            <button
+              onClick={toggleMute}
+              className="p-1.5 text-[#666] hover:text-white active:text-white"
+              title={isMuted ? "Unmute" : "Mute"}
+            >
+              {isMuted ? <VolumeX size={14} /> : <Volume2 size={14} />}
+            </button>
+            <button
+              onClick={handleClose}
+              className="p-1.5 text-[#666] hover:text-white active:text-white"
+            >
+              <X size={14} />
+            </button>
+          </div>
+        </div>
+        <p className="text-sm text-[#ddd] leading-relaxed">{current?.text}</p>
+        <div className="mt-3 flex items-center justify-between">
+          <span className="text-[9px] text-[#555]">
+            {currentIndex + 1} / {messages.length}
+          </span>
+          {isMobile ? (
+            <div className="flex items-center gap-2">
+              <button
+                onClick={handleClose}
+                className="px-3 py-1.5 text-xs text-[#888] bg-white/5 rounded active:bg-white/15"
+              >
+                Close
+              </button>
+              {!isLast && (
+                <button
+                  onClick={handleNext}
+                  className="px-3 py-1.5 text-xs text-white bg-white/10 rounded active:bg-white/25"
+                >
+                  Next
+                </button>
+              )}
+            </div>
+          ) : (
+            <span className="text-[9px] text-[#555]">
+              {isLast
+                ? "Press Enter or Esc to close"
+                : "Press Enter to continue"}
+            </span>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+async function fetchCharacterResponses(
+  role: string,
+): Promise<{ text: string; audioUrl?: string }[]> {
+  try {
+    const res = await fetch("/api/character-responses", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ role }),
+    });
+    if (!res.ok) throw new Error("Failed to fetch responses");
+    const data = await res.json();
+    return (
+      data.messages || [
+        { text: "Hello there! How can I help you?" },
+        { text: "It's a beautiful day, isn't it?" },
+      ]
+    );
+  } catch {
+    return [
+      { text: "Hello there! How can I help you?" },
+      { text: "It's a beautiful day, isn't it?" },
+    ];
+  }
+}

+ 24 - 0
examples/game-engine/components/hud/damage-effect.tsx

@@ -0,0 +1,24 @@
+"use client";
+
+import { useState, useEffect } from "react";
+
+interface DamageEffectProps {
+  lastDamageTime: number | null;
+}
+
+export function DamageEffect({ lastDamageTime }: DamageEffectProps) {
+  const [show, setShow] = useState(false);
+
+  useEffect(() => {
+    if (!lastDamageTime) return;
+    setShow(true);
+    const timer = setTimeout(() => setShow(false), 400);
+    return () => clearTimeout(timer);
+  }, [lastDamageTime]);
+
+  if (!show) return null;
+
+  return (
+    <div className="absolute inset-0 pointer-events-none z-20 animate-screen-flash bg-red-600/30 rounded-none" />
+  );
+}

+ 46 - 0
examples/game-engine/components/hud/damage-sound.tsx

@@ -0,0 +1,46 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { useEditorStore } from "@/lib/store";
+
+const DAMAGE_SOUND_URL =
+  "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/hit-OaUo19QcLdyFfqy5SMfEWyAF3ZgyxR.mp3";
+
+export function DamageSound() {
+  const isPlaying = useEditorStore((s) => s.isPlaying);
+  const lastDamageTime = useEditorStore((s) => s.lastDamageTime);
+  const health = useEditorStore((s) => s.health);
+  const audioRef = useRef<HTMLAudioElement | null>(null);
+  const lastPlayedRef = useRef<number>(0);
+  const [loaded, setLoaded] = useState(false);
+
+  useEffect(() => {
+    const audio = new Audio();
+    audio.src = DAMAGE_SOUND_URL;
+    audio.preload = "auto";
+    audio.addEventListener("canplaythrough", () => setLoaded(true));
+    audio.addEventListener("error", () => setLoaded(false));
+    audioRef.current = audio;
+
+    return () => {
+      audio.pause();
+      audio.src = "";
+    };
+  }, []);
+
+  useEffect(() => {
+    if (!isPlaying || !lastDamageTime || health <= 0 || !loaded) return;
+
+    const now = Date.now();
+    if (now - lastPlayedRef.current < 300) return;
+    lastPlayedRef.current = now;
+
+    if (audioRef.current) {
+      audioRef.current.currentTime = 0;
+      audioRef.current.volume = 0.5;
+      audioRef.current.play().catch(() => {});
+    }
+  }, [isPlaying, lastDamageTime, health, loaded]);
+
+  return null;
+}

+ 46 - 0
examples/game-engine/components/hud/death-sound.tsx

@@ -0,0 +1,46 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { useEditorStore } from "@/lib/store";
+
+const DEATH_SOUND_URL =
+  "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/death-y5RAJQHhGgzagKglqKEYKzMFc84QcR.mp3";
+
+export function DeathSound() {
+  const health = useEditorStore((s) => s.health);
+  const isPlaying = useEditorStore((s) => s.isPlaying);
+  const audioRef = useRef<HTMLAudioElement | null>(null);
+  const [prevHealth, setPrevHealth] = useState(100);
+  const [loaded, setLoaded] = useState(false);
+
+  useEffect(() => {
+    const audio = new Audio();
+    audio.src = DEATH_SOUND_URL;
+    audio.preload = "auto";
+    audio.addEventListener("canplaythrough", () => setLoaded(true));
+    audio.addEventListener("error", () => setLoaded(false));
+    audioRef.current = audio;
+
+    return () => {
+      audio.pause();
+      audio.src = "";
+    };
+  }, []);
+
+  useEffect(() => {
+    if (
+      isPlaying &&
+      prevHealth > 0 &&
+      health <= 0 &&
+      audioRef.current &&
+      loaded
+    ) {
+      audioRef.current.currentTime = 0;
+      audioRef.current.volume = 0.7;
+      audioRef.current.play().catch(() => {});
+    }
+    setPrevHealth(health);
+  }, [health, isPlaying, prevHealth, loaded]);
+
+  return null;
+}

+ 51 - 0
examples/game-engine/components/hud/game-over.tsx

@@ -0,0 +1,51 @@
+"use client";
+
+import { useEditorStore } from "@/lib/store";
+
+export function GameOverScreen() {
+  const setIsPlaying = useEditorStore((s) => s.setIsPlaying);
+  const setHealth = useEditorStore((s) => s.setHealth);
+  const setShield = useEditorStore((s) => s.setShield);
+  const maxHealth = useEditorStore((s) => s.maxHealth);
+  const maxShield = useEditorStore((s) => s.maxShield);
+
+  const handleRestart = () => {
+    setHealth(maxHealth);
+    setShield(maxShield);
+    setIsPlaying(false);
+    setTimeout(() => setIsPlaying(true), 100);
+  };
+
+  const handleExit = () => {
+    setHealth(maxHealth);
+    setShield(maxShield);
+    setIsPlaying(false);
+  };
+
+  return (
+    <div className="absolute inset-0 z-30 flex items-center justify-center bg-black/80 animate-fade-in">
+      <div className="text-center px-4">
+        <h1 className="text-3xl sm:text-5xl font-bold text-red-500 mb-2">
+          GAME OVER
+        </h1>
+        <p className="text-[#888] mb-8 text-sm sm:text-base">
+          You have been defeated
+        </p>
+        <div className="flex gap-3 justify-center">
+          <button
+            onClick={handleRestart}
+            className="px-6 py-2.5 sm:py-2 bg-white/10 hover:bg-white/20 active:bg-white/30 text-white rounded-lg text-sm transition-colors min-w-[100px]"
+          >
+            Restart
+          </button>
+          <button
+            onClick={handleExit}
+            className="px-6 py-2.5 sm:py-2 bg-white/5 hover:bg-white/10 active:bg-white/20 text-[#888] rounded-lg text-sm transition-colors min-w-[100px]"
+          >
+            Exit
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 58 - 0
examples/game-engine/components/hud/health-bar.tsx

@@ -0,0 +1,58 @@
+"use client";
+
+import { useEditorStore } from "@/lib/store";
+import { useIsMobile } from "@/lib/use-mobile";
+
+export function HealthBar() {
+  const health = useEditorStore((s) => s.health);
+  const shield = useEditorStore((s) => s.shield);
+  const maxHealth = useEditorStore((s) => s.maxHealth);
+  const maxShield = useEditorStore((s) => s.maxShield);
+  const isMobile = useIsMobile();
+
+  const healthPct = (health / maxHealth) * 100;
+  const shieldPct = (shield / maxShield) * 100;
+
+  return (
+    <div
+      className={`absolute ${isMobile ? "top-14 left-3" : "bottom-4 left-4"} z-10 flex flex-col gap-1.5 pointer-events-none`}
+    >
+      {/* Shield bar */}
+      <div className="flex items-center gap-2">
+        <div className="text-[10px] text-blue-400 w-5 text-right font-mono">
+          {Math.round(shield)}
+        </div>
+        <div
+          className={`${isMobile ? "w-28" : "w-40"} h-2 bg-[#1a1a1a] rounded-full overflow-hidden`}
+        >
+          <div
+            className="h-full bg-gradient-to-r from-blue-600 to-blue-400 transition-all duration-300"
+            style={{ width: `${shieldPct}%` }}
+          />
+        </div>
+      </div>
+      {/* Health bar */}
+      <div className="flex items-center gap-2">
+        <div
+          className={`text-[10px] w-5 text-right font-mono ${
+            health < 30 ? "text-red-400 animate-pulse-low" : "text-green-400"
+          }`}
+        >
+          {Math.round(health)}
+        </div>
+        <div
+          className={`${isMobile ? "w-28" : "w-40"} h-2 bg-[#1a1a1a] rounded-full overflow-hidden`}
+        >
+          <div
+            className={`h-full transition-all duration-300 ${
+              health < 30
+                ? "bg-gradient-to-r from-red-600 to-red-400 animate-pulse-low"
+                : "bg-gradient-to-r from-green-600 to-green-400"
+            }`}
+            style={{ width: `${healthPct}%` }}
+          />
+        </div>
+      </div>
+    </div>
+  );
+}

+ 284 - 0
examples/game-engine/components/hud/in-game-prompt.tsx

@@ -0,0 +1,284 @@
+"use client";
+
+import type React from "react";
+import { useState, useEffect, useRef } from "react";
+import { ArrowUp, Loader2, MessageSquare } from "lucide-react";
+import { useEditorStore } from "@/lib/store";
+import { useIsMobile } from "@/lib/use-mobile";
+import type {
+  ObjectType,
+  TransformMode,
+  SceneObject,
+  Material,
+  Physics,
+  Damage,
+} from "@/lib/types";
+
+export function InGamePrompt() {
+  const [isOpen, setIsOpen] = useState(false);
+  const [prompt, setPrompt] = useState("");
+  const [isProcessing, setIsProcessing] = useState(false);
+  const [previousPrompts, setPreviousPrompts] = useState<string[]>([]);
+  const textareaRef = useRef<HTMLTextAreaElement>(null);
+  const isMobile = useIsMobile();
+
+  const {
+    addObject,
+    scenes,
+    activeSceneId,
+    updateObjectTransform,
+    updateObjectMaterial,
+    updateObjectPhysics,
+    updateDamage,
+    selectObject,
+    setTransformMode,
+    createCustomObject,
+    isPlaying,
+    setIsPromptOpen,
+  } = useEditorStore();
+
+  const activeScene =
+    scenes.find((scene) => scene.id === activeSceneId) || scenes[0];
+  const objects = activeScene ? activeScene.objects : [];
+
+  const processAction = (action: { function: string; args: unknown[] }) => {
+    try {
+      switch (action.function) {
+        case "addObject":
+          addObject(action.args[0] as ObjectType);
+          break;
+        case "createCustomObject":
+          createCustomObject(
+            action.args[0] as ObjectType,
+            action.args[1] as Partial<SceneObject>,
+          );
+          break;
+        case "updateObjectTransform":
+          updateObjectTransform(
+            action.args[0] as string,
+            action.args[1] as Partial<
+              Pick<SceneObject, "position" | "rotation" | "scale">
+            >,
+          );
+          break;
+        case "updateObjectMaterial":
+          updateObjectMaterial(
+            action.args[0] as string,
+            action.args[1] as Partial<Material>,
+          );
+          break;
+        case "updateObjectPhysics":
+          updateObjectPhysics(
+            action.args[0] as string,
+            action.args[1] as Partial<Physics>,
+          );
+          break;
+        case "updateDamage":
+          updateDamage(
+            action.args[0] as string,
+            action.args[1] as Partial<Damage>,
+          );
+          break;
+        case "selectObject":
+          selectObject(action.args[0] as string);
+          break;
+        case "setTransformMode":
+          setTransformMode(action.args[0] as TransformMode);
+          break;
+      }
+    } catch (error) {
+      console.error("Error processing action:", error, action);
+    }
+  };
+
+  const handleSubmit = async (e?: React.FormEvent) => {
+    if (e) e.preventDefault();
+    if (!prompt.trim() || isProcessing) return;
+
+    setIsProcessing(true);
+    const currentPrompt = prompt;
+    setPreviousPrompts((prev) => [...prev.slice(-4), currentPrompt]);
+    setIsOpen(false);
+    setIsPromptOpen(false);
+
+    try {
+      const response = await fetch("/api/ai-game", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          prompt: currentPrompt,
+          objects,
+          previousPrompts,
+        }),
+      });
+
+      if (!response.ok || !response.body) {
+        throw new Error("Failed to get AI response");
+      }
+
+      const reader = response.body.getReader();
+      const decoder = new TextDecoder();
+      let buffer = "";
+
+      while (true) {
+        const { done, value } = await reader.read();
+        if (done) break;
+
+        const chunk = decoder.decode(value, { stream: true });
+        buffer += chunk;
+
+        let startIndex = 0;
+        while (true) {
+          const openBrace = buffer.indexOf("{", startIndex);
+          if (openBrace === -1) break;
+
+          let depth = 0;
+          let closeBrace = -1;
+
+          for (let i = openBrace; i < buffer.length; i++) {
+            if (buffer[i] === "{") depth++;
+            else if (buffer[i] === "}") {
+              depth--;
+              if (depth === 0) {
+                closeBrace = i;
+                break;
+              }
+            }
+          }
+
+          if (closeBrace === -1) {
+            startIndex = openBrace + 1;
+            break;
+          }
+
+          const jsonStr = buffer.substring(openBrace, closeBrace + 1);
+
+          try {
+            const jsonObj = JSON.parse(jsonStr);
+            if (
+              jsonObj &&
+              typeof jsonObj.function === "string" &&
+              Array.isArray(jsonObj.args)
+            ) {
+              processAction(jsonObj);
+            }
+            buffer =
+              buffer.substring(0, openBrace) + buffer.substring(closeBrace + 1);
+            startIndex = 0;
+          } catch {
+            startIndex = openBrace + 1;
+          }
+        }
+      }
+    } catch (error) {
+      console.error("Error processing AI prompt:", error);
+    } finally {
+      setIsProcessing(false);
+      setPrompt("");
+    }
+  };
+
+  useEffect(() => {
+    if (!isPlaying) {
+      setIsOpen(false);
+      setIsPromptOpen(false);
+      return;
+    }
+
+    if (isMobile) return;
+
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if ((e.metaKey || e.ctrlKey) && e.key === "/") {
+        e.preventDefault();
+        const newIsOpen = !isOpen;
+        setIsOpen(newIsOpen);
+        setIsPromptOpen(newIsOpen);
+      }
+    };
+
+    window.addEventListener("keydown", handleKeyDown);
+    return () => window.removeEventListener("keydown", handleKeyDown);
+  }, [isPlaying, isOpen, setIsPromptOpen, isMobile]);
+
+  useEffect(() => {
+    if (isOpen && textareaRef.current) {
+      setTimeout(() => textareaRef.current?.focus(), 100);
+    }
+  }, [isOpen]);
+
+  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+    if (e.key === "Enter" && !e.shiftKey) {
+      e.preventDefault();
+      handleSubmit();
+    }
+  };
+
+  const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+    setPrompt(e.target.value);
+    e.target.style.height = "auto";
+    e.target.style.height = `${e.target.scrollHeight}px`;
+  };
+
+  if (!isPlaying) return null;
+
+  if (!isOpen) {
+    if (isMobile) {
+      return (
+        <button
+          onClick={() => {
+            setIsOpen(true);
+            setIsPromptOpen(true);
+          }}
+          className="absolute top-14 right-3 z-30 w-11 h-11 flex items-center justify-center rounded-full bg-black/40 backdrop-blur-sm text-white/70 active:bg-white/20 border border-white/10"
+        >
+          <MessageSquare size={18} />
+        </button>
+      );
+    }
+    return null;
+  }
+
+  return (
+    <div
+      className={`absolute ${isMobile ? "top-14 left-2 right-2 bottom-auto" : "top-[50px] left-0 right-0 bottom-0 flex items-center justify-center"} z-40 animate-fade-in`}
+    >
+      <div className="flex items-center justify-center">
+        <div
+          className={`flex items-center gap-2 bg-black/70 backdrop-blur-md border border-white/20 ${isMobile ? "rounded-xl w-full" : "rounded-full"} shadow-lg overflow-hidden ${isMobile ? "" : "max-w-[600px]"}`}
+        >
+          <div className={isMobile ? "pl-3" : "pl-5"}>
+            <MessageSquare className="h-5 w-5 text-white/70" />
+          </div>
+          <form
+            onSubmit={handleSubmit}
+            className="flex items-center flex-1 min-w-0"
+          >
+            <textarea
+              ref={textareaRef}
+              value={prompt}
+              onChange={handleTextareaChange}
+              onKeyDown={handleKeyDown}
+              placeholder="Describe what to add or change..."
+              className={`bg-transparent border-none px-3 py-4 ${isMobile ? "flex-1 min-w-0 text-base" : "w-[400px] text-sm"} text-white placeholder-gray-400 focus:outline-none resize-none overflow-hidden min-h-[44px] max-h-[200px]`}
+              rows={1}
+              style={{ height: "auto" }}
+            />
+            <div className="flex items-center gap-1 mr-3">
+              <button
+                type="submit"
+                className="bg-white/15 text-white p-2 rounded-full hover:bg-white/25 active:bg-white/35 transition-colors disabled:opacity-30"
+                disabled={!prompt.trim() || isProcessing}
+              >
+                {isProcessing ? (
+                  <Loader2 className="h-5 w-5 animate-spin" />
+                ) : (
+                  <ArrowUp className="h-5 w-5" />
+                )}
+              </button>
+            </div>
+          </form>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 53 - 0
examples/game-engine/components/hud/interaction-prompt.tsx

@@ -0,0 +1,53 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useEditorStore } from "@/lib/store";
+import { useIsMobile } from "@/lib/use-mobile";
+
+export function InteractionPrompt() {
+  const [showPrompt, setShowPrompt] = useState(false);
+  const isPlaying = useEditorStore((s) => s.isPlaying);
+  const isMobile = useIsMobile();
+
+  useEffect(() => {
+    if (!isPlaying) {
+      setShowPrompt(false);
+      return;
+    }
+
+    const handleNearby = () => setShowPrompt(true);
+    const handleFar = () => setShowPrompt(false);
+    const handleDialogOpen = () => setShowPrompt(false);
+
+    window.addEventListener("character-nearby", handleNearby);
+    window.addEventListener("character-far", handleFar);
+    window.addEventListener("dialog-open", handleDialogOpen);
+
+    return () => {
+      window.removeEventListener("character-nearby", handleNearby);
+      window.removeEventListener("character-far", handleFar);
+      window.removeEventListener("dialog-open", handleDialogOpen);
+    };
+  }, [isPlaying]);
+
+  if (!isPlaying || !showPrompt) return null;
+
+  if (isMobile) {
+    return (
+      <button
+        onClick={() => {
+          window.dispatchEvent(new CustomEvent("request-character-interact"));
+        }}
+        className="absolute bottom-28 left-1/2 -translate-x-1/2 z-20 bg-white/20 backdrop-blur-sm text-white px-6 py-3 rounded-full text-sm font-medium active:bg-white/40 border border-white/30"
+      >
+        Tap to interact
+      </button>
+    );
+  }
+
+  return (
+    <div className="absolute bottom-10 left-1/2 -translate-x-1/2 z-10 bg-black/70 text-white px-4 py-2 rounded-md text-sm">
+      Press &quot;E&quot; to interact
+    </div>
+  );
+}

+ 16 - 0
examples/game-engine/components/hud/loading-spinner.tsx

@@ -0,0 +1,16 @@
+"use client";
+
+import { Loader2 } from "lucide-react";
+import { useEditorStore } from "@/lib/store";
+
+export function LoadingSpinner() {
+  const isLoading = useEditorStore((s) => s.isLoading);
+
+  if (!isLoading) return null;
+
+  return (
+    <div className="absolute top-4 right-4 bg-black/60 rounded-full p-2 shadow-md z-10 backdrop-blur-sm">
+      <Loader2 className="h-5 w-5 animate-spin text-white" />
+    </div>
+  );
+}

+ 191 - 0
examples/game-engine/components/hud/touch-controls.tsx

@@ -0,0 +1,191 @@
+"use client";
+
+import { useEffect, useRef, useCallback } from "react";
+import { ArrowUp } from "lucide-react";
+import { touchMoveState } from "@/lib/touch-state";
+import { useEditorStore } from "@/lib/store";
+
+const JOYSTICK_SIZE = 120;
+const KNOB_SIZE = 48;
+const MAX_DIST = (JOYSTICK_SIZE - KNOB_SIZE) / 2;
+
+export function TouchControls() {
+  const isPlaying = useEditorStore((s) => s.isPlaying);
+  const health = useEditorStore((s) => s.health);
+  const joystickRef = useRef<HTMLDivElement>(null);
+  const knobRef = useRef<HTMLDivElement>(null);
+  const joystickTouchId = useRef<number | null>(null);
+  const joystickCenter = useRef<{ x: number; y: number } | null>(null);
+  const lookTouchRef = useRef<{ x: number; y: number } | null>(null);
+
+  const isGameOver = health <= 0;
+
+  const resetJoystick = useCallback(() => {
+    joystickTouchId.current = null;
+    joystickCenter.current = null;
+    touchMoveState.forward = 0;
+    touchMoveState.right = 0;
+    touchMoveState.sprint = false;
+    if (knobRef.current) {
+      knobRef.current.style.transform = "translate(-50%, -50%)";
+    }
+  }, []);
+
+  useEffect(() => {
+    if (!isPlaying || isGameOver) {
+      resetJoystick();
+      return;
+    }
+
+    const zone = joystickRef.current;
+    if (!zone) return;
+
+    const handleStart = (e: TouchEvent) => {
+      if (joystickTouchId.current !== null) return;
+      for (let i = 0; i < e.changedTouches.length; i++) {
+        const t = e.changedTouches[i]!;
+        const rect = zone.getBoundingClientRect();
+        if (
+          t.clientX >= rect.left &&
+          t.clientX <= rect.right &&
+          t.clientY >= rect.top &&
+          t.clientY <= rect.bottom
+        ) {
+          joystickTouchId.current = t.identifier;
+          joystickCenter.current = { x: t.clientX, y: t.clientY };
+          break;
+        }
+      }
+    };
+
+    const handleMove = (e: TouchEvent) => {
+      if (joystickTouchId.current === null || !joystickCenter.current) return;
+      for (let i = 0; i < e.changedTouches.length; i++) {
+        const t = e.changedTouches[i]!;
+        if (t.identifier !== joystickTouchId.current) continue;
+
+        let dx = t.clientX - joystickCenter.current.x;
+        let dy = t.clientY - joystickCenter.current.y;
+        const dist = Math.sqrt(dx * dx + dy * dy);
+        const clamped = Math.min(dist, MAX_DIST);
+        if (dist > 0) {
+          dx = (dx / dist) * clamped;
+          dy = (dy / dist) * clamped;
+        }
+
+        const normalized = dist > 0 ? Math.min(dist / MAX_DIST, 1) : 0;
+        touchMoveState.right = (dx / MAX_DIST) * normalized;
+        touchMoveState.forward = -(dy / MAX_DIST) * normalized;
+
+        if (knobRef.current) {
+          knobRef.current.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
+        }
+        break;
+      }
+    };
+
+    const handleEnd = (e: TouchEvent) => {
+      if (joystickTouchId.current === null) return;
+      for (let i = 0; i < e.changedTouches.length; i++) {
+        if (e.changedTouches[i]!.identifier === joystickTouchId.current) {
+          resetJoystick();
+          break;
+        }
+      }
+    };
+
+    zone.addEventListener("touchstart", handleStart, { passive: true });
+    window.addEventListener("touchmove", handleMove, { passive: true });
+    window.addEventListener("touchend", handleEnd, { passive: true });
+    window.addEventListener("touchcancel", handleEnd, { passive: true });
+
+    return () => {
+      zone.removeEventListener("touchstart", handleStart);
+      window.removeEventListener("touchmove", handleMove);
+      window.removeEventListener("touchend", handleEnd);
+      window.removeEventListener("touchcancel", handleEnd);
+      resetJoystick();
+    };
+  }, [isPlaying, isGameOver, resetJoystick]);
+
+  const handleLookStart = useCallback((e: React.TouchEvent) => {
+    const touch = e.touches[0];
+    if (touch) {
+      lookTouchRef.current = { x: touch.clientX, y: touch.clientY };
+    }
+  }, []);
+
+  const handleLookMove = useCallback((e: React.TouchEvent) => {
+    const touch = e.touches[0];
+    if (!touch || !lookTouchRef.current) return;
+    touchMoveState.lookDeltaX +=
+      (touch.clientX - lookTouchRef.current.x) * 0.004;
+    touchMoveState.lookDeltaY +=
+      (touch.clientY - lookTouchRef.current.y) * 0.004;
+    lookTouchRef.current = { x: touch.clientX, y: touch.clientY };
+  }, []);
+
+  const handleLookEnd = useCallback(() => {
+    lookTouchRef.current = null;
+  }, []);
+
+  const handleJump = useCallback(() => {
+    touchMoveState.jump = true;
+    setTimeout(() => {
+      touchMoveState.jump = false;
+    }, 150);
+  }, []);
+
+  if (!isPlaying || isGameOver) return null;
+
+  return (
+    <>
+      {/* Joystick zone (left half, bottom) */}
+      <div
+        ref={joystickRef}
+        className="fixed left-0 bottom-0 z-20 pointer-events-auto"
+        style={{ width: "45vw", height: "40vh" }}
+      >
+        {/* Joystick base */}
+        <div
+          className="absolute rounded-full border border-white/20 bg-white/5"
+          style={{
+            width: JOYSTICK_SIZE,
+            height: JOYSTICK_SIZE,
+            left: 60 - JOYSTICK_SIZE / 2,
+            bottom: 60 - JOYSTICK_SIZE / 2,
+          }}
+        >
+          {/* Knob */}
+          <div
+            ref={knobRef}
+            className="absolute left-1/2 top-1/2 rounded-full bg-white/25 border border-white/30"
+            style={{
+              width: KNOB_SIZE,
+              height: KNOB_SIZE,
+              transform: "translate(-50%, -50%)",
+            }}
+          />
+        </div>
+      </div>
+
+      {/* Look zone (right half) */}
+      <div
+        className="fixed right-0 top-10 bottom-0 z-20 pointer-events-auto"
+        style={{ width: "55vw" }}
+        onTouchStart={handleLookStart}
+        onTouchMove={handleLookMove}
+        onTouchEnd={handleLookEnd}
+        onTouchCancel={handleLookEnd}
+      />
+
+      {/* Jump button */}
+      <button
+        onTouchStart={handleJump}
+        className="fixed bottom-6 right-6 z-30 w-14 h-14 rounded-full bg-white/15 backdrop-blur-sm border border-white/20 flex items-center justify-center active:bg-white/30 pointer-events-auto"
+      >
+        <ArrowUp size={24} className="text-white/70" />
+      </button>
+    </>
+  );
+}

+ 12 - 0
examples/game-engine/eslint.config.js

@@ -0,0 +1,12 @@
+import { nextJsConfig } from "@internal/eslint-config/next-js";
+
+/** @type {import("eslint").Linter.Config[]} */
+export default [
+  ...nextJsConfig,
+  {
+    rules: {
+      "react/prop-types": "off",
+      "react/no-unknown-property": "off",
+    },
+  },
+];

+ 71 - 0
examples/game-engine/lib/ai-game-prompt.ts

@@ -0,0 +1,71 @@
+import type { SceneObject } from "./types";
+
+export function generateGameAIPrompt(
+  userPrompt: string,
+  objects: SceneObject[] = [],
+  previousPrompts: string[] = [],
+): string {
+  const simplifiedObjects = objects.map((obj) => ({
+    id: obj.id,
+    name: obj.name,
+    type: obj.type,
+    position: obj.position,
+    rotation: obj.rotation,
+    scale: obj.scale,
+    material: obj.material,
+    physics: obj.physics,
+    lightType: obj.lightType,
+    intensity: obj.intensity,
+    damage: obj.damage,
+    shapeData: obj.shapeData,
+    meshData: obj.meshData,
+  }));
+
+  return `You are an AI assistant that helps users create and modify 3D scenes.
+The user has given you this instruction: "${userPrompt}".
+
+You have access to these functions:
+- addObject(type): Creates a new object of type "box", "sphere", "cylinder", "cone", "torus", "plane", "light", "capsule", "extrude", "tetrahedron", "octahedron", "dodecahedron", "icosahedron", "knot", "tube", "shape", "mesh"
+- createCustomObject(type, properties): Creates a new object with custom properties. Properties can include:
+  * name: string
+  * position: [x, y, z]
+  * rotation: [x, y, z] (For plane objects, default rotation is [-1.5708, 0, 0])
+  * scale: [x, y, z] (For plane objects, Z scale is always 1)
+  * material: { color, metalness, roughness, emissive, emissiveIntensity }
+  * physics: { mass, isStatic, restitution, friction, colliderType }
+  * damage: { amount, enabled }
+  * lightType: "ambient", "directional", "point", or "spot"
+  * intensity: number
+  * distance: number
+  * decay: number
+  * angle: number
+  * penumbra: number
+  * shapeData: { points: [x,y][], holes?: [x,y][][] }
+  * meshData: { vertices: number[], indices: number[], normals?: number[], uvs?: number[] }
+- updateObjectTransform(id, {position, rotation, scale}): Updates an object's transform
+- updateObjectMaterial(id, {color, metalness, roughness, emissive, emissiveIntensity}): Updates material
+- updateObjectPhysics(id, {mass, isStatic, restitution, friction, colliderType}): Updates physics
+- updateDamage(id, {amount, enabled}): Updates damage properties
+- selectObject(id): Selects an object
+- setTransformMode(mode): Sets transform mode to "select", "translate", "rotate", or "scale"
+
+Current objects in the scene:
+${JSON.stringify(simplifiedObjects, null, 2)}
+
+Previous user instructions:
+${previousPrompts.map((p) => `- "${p}"`).join("\n")}
+
+Respond with a series of JSON objects, one per line (JSONL format). Each JSON object should have a "function" property and "args" property.
+Example:
+{"function": "createCustomObject", "args": ["box", {"name": "Large Red Box", "position": [0, 1, 0], "scale": [2, 2, 2], "material": {"color": "#ff0000"}, "physics": {"mass": 5, "isStatic": false, "restitution": 0.5}}]}
+{"function": "updateObjectTransform", "args": ["object-id", {"position": [0, -1, 0], "scale": [10, 1, 10]}]}
+
+IMPORTANT CONSTRAINTS:
+1. For plane objects, the Z scale must always be 1.
+2. For plane objects, the default rotation is [-1.5708, 0, 0] to make them horizontal.
+3. Only use the functions listed above.
+4. Your response must be valid JSONL format with one JSON object per line. Do not include any explanations.
+5. Do not wrap your response in an array. Each line should be a separate, complete JSON object.
+6. Each JSON object MUST have exactly this format: {"function": "functionName", "args": [arg1, arg2, ...]}
+7. Do not use JavaScript expressions like Math.PI in your JSON. Use the actual numeric values instead.`;
+}

+ 56 - 0
examples/game-engine/lib/ai-prompt.ts

@@ -0,0 +1,56 @@
+import { yamlPrompt } from "@json-render/yaml";
+import { buildEditUserPrompt } from "@json-render/core";
+import type { Spec } from "@json-render/core";
+import { stringify } from "yaml";
+import { catalog } from "./catalog";
+
+const GAME_RULES = [
+  "Use numeric values only (no Math.PI, use 3.14159)",
+  "For GamePlane as ground: rotation [-1.5708, 0, 0], scale [size, size, 1]",
+  "Use descriptive keys for new objects (e.g. 'obj-tree-1-trunk', 'obj-tree-1-canopy')",
+  "Object element keys start with 'obj-'",
+  "The root element 'scene' is a Group whose children lists all top-level element keys",
+  "When adding an object: add the element AND append the key to the scene children",
+  "COMPOSE objects into recognizable structures. TREE RECIPE: trunk = GameCylinder with radiusTop 0.12-0.2, radiusBottom 0.15-0.25, height 3-6, color '#8B4513' or '#5C3317', position Y = height/2. Canopy = GameSphere with radius 1.5-3, color '#228B22' or '#2E8B57', position Y = trunk height + canopy radius * 0.7. The trunk must be TALL and THIN (radius << height). HOUSE: GameBox walls (width 3, height 2.5, depth 3) + GameCone roof on top. LAMP: GameCylinder radiusTop 0.05, radiusBottom 0.05, height 3 + GameSphere radius 0.2 with emissive material at top. NEVER scatter disconnected primitives randomly.",
+  "SPATIAL COHERENCE: related parts share the same X/Z base. Stack vertically with exact Y math: if trunk height=4, trunk Y=2 (half height). Canopy radius=2, canopy Y = 4 + 2*0.7 = 5.4. Objects that belong together must visually touch or overlap.",
+  "Be AMBITIOUS and THOROUGH: when the user asks for something, go all-in — build 10-20+ composed structures with varied sizes, not just a handful of loose shapes",
+  "REPEATING DETAILS: when placing repeating elements (road dashes, fence posts, windows, floor tiles, pillars, streetlights along a road), space them evenly and use ENOUGH to cover the full length/area. A 100-unit road needs 20+ dashes, not 5. A building face 10 units wide needs 4-5 windows per floor, not 1. Do NOT be lazy with repetition — density sells the scene.",
+  "Always set castShadow on lights and objects, receiveShadow on ground/floors, and use materials with varied colors, metalness, roughness for visual richness",
+  "When building environments, include atmosphere (Sky, Fog, ambient light), ground plane with receiveShadow, and decorative elements — make it feel like a complete, coherent scene",
+];
+
+function serializeSpec(spec: Spec): string {
+  return stringify(spec, { indent: 2, lineWidth: 0 });
+}
+
+export function generateSystemPrompt(): string {
+  return yamlPrompt(catalog, {
+    system:
+      "You are an expert 3D level designer AI for a json-render game engine. You build rich, detailed, immersive scenes using YAML patch operations. When the user describes a scene or asks for changes, go big — create dense, visually interesting environments with many objects, proper lighting, shadows, physics, varied materials, and atmospheric effects. Think like a AAA game level designer, not a minimal prototype builder.",
+    mode: "standalone",
+    editModes: ["merge"],
+    customRules: GAME_RULES,
+  });
+}
+
+export function generateUserPrompt(
+  userPrompt: string,
+  currentSpec: Spec,
+  previousPrompts: string[] = [],
+): string {
+  const prevContext =
+    previousPrompts.length > 0
+      ? `Previous instructions:\n${previousPrompts.join("\n")}\n\n`
+      : "";
+
+  return (
+    prevContext +
+    buildEditUserPrompt({
+      prompt: userPrompt,
+      currentSpec,
+      config: { modes: ["merge"] },
+      format: "yaml",
+      serializer: serializeSpec,
+    })
+  );
+}

+ 9 - 0
examples/game-engine/lib/bone-utils.ts

@@ -0,0 +1,9 @@
+import type * as THREE from "three";
+
+export function resetBoneScales(object: THREE.Object3D) {
+  object.traverse((node) => {
+    if ((node as THREE.Bone).isBone) {
+      node.scale.set(1, 1, 1);
+    }
+  });
+}

+ 484 - 0
examples/game-engine/lib/catalog.ts

@@ -0,0 +1,484 @@
+import { z } from "zod";
+import { defineCatalog } from "@json-render/core";
+import { schema } from "@json-render/react/schema";
+import { threeComponentDefinitions } from "@json-render/react-three-fiber/catalog";
+
+const vector3Schema = z.tuple([z.number(), z.number(), z.number()]);
+
+const materialSchema = z.object({
+  color: z.string().nullable(),
+  metalness: z.number().nullable(),
+  roughness: z.number().nullable(),
+  emissive: z.string().nullable(),
+  emissiveIntensity: z.number().nullable(),
+  opacity: z.number().nullable(),
+  transparent: z.boolean().nullable(),
+  wireframe: z.boolean().nullable(),
+});
+
+const transformProps = {
+  position: vector3Schema.nullable(),
+  rotation: vector3Schema.nullable(),
+  scale: vector3Schema.nullable(),
+} as const;
+
+const shadowProps = {
+  castShadow: z.boolean().nullable(),
+  receiveShadow: z.boolean().nullable(),
+} as const;
+
+const physicsSchema = z.object({
+  mass: z.number().nullable(),
+  isStatic: z.boolean().nullable(),
+  restitution: z.number().nullable(),
+  friction: z.number().nullable(),
+  colliderType: z.enum(["cuboid", "ball", "capsule", "none"]).nullable(),
+});
+
+const damageSchema = z.object({
+  amount: z.number().nullable(),
+  enabled: z.boolean().nullable(),
+});
+
+const gameTransformShadowPhysicsDamage = {
+  ...transformProps,
+  ...shadowProps,
+  material: materialSchema.nullable(),
+  physics: physicsSchema.nullable(),
+  damage: damageSchema.nullable(),
+  objectId: z.string().nullable(),
+};
+
+export const gameComponentDefinitions = {
+  // Keep all non-primitive R3F components as-is
+  AmbientLight: threeComponentDefinitions.AmbientLight,
+  DirectionalLight: threeComponentDefinitions.DirectionalLight,
+  PointLight: threeComponentDefinitions.PointLight,
+  SpotLight: threeComponentDefinitions.SpotLight,
+  Group: threeComponentDefinitions.Group,
+  Model: threeComponentDefinitions.Model,
+  Environment: threeComponentDefinitions.Environment,
+  Fog: threeComponentDefinitions.Fog,
+  GridHelper: threeComponentDefinitions.GridHelper,
+  Text3D: threeComponentDefinitions.Text3D,
+  Sparkles: threeComponentDefinitions.Sparkles,
+  Stars: threeComponentDefinitions.Stars,
+  Sky: threeComponentDefinitions.Sky,
+  Cloud: threeComponentDefinitions.Cloud,
+  ContactShadows: threeComponentDefinitions.ContactShadows,
+  Float: threeComponentDefinitions.Float,
+  EffectComposer: threeComponentDefinitions.EffectComposer,
+  Bloom: threeComponentDefinitions.Bloom,
+  Vignette: threeComponentDefinitions.Vignette,
+  PerspectiveCamera: threeComponentDefinitions.PerspectiveCamera,
+  OrbitControls: threeComponentDefinitions.OrbitControls,
+
+  // Game-specific primitives with physics & damage
+  GameBox: {
+    props: z.object({
+      ...gameTransformShadowPhysicsDamage,
+      width: z.number().nullable(),
+      height: z.number().nullable(),
+      depth: z.number().nullable(),
+    }),
+    description:
+      "Box with optional physics and damage. ALWAYS set width, height, depth to create varied shapes — wide flat platforms (4, 0.2, 4), tall walls (0.3, 3, 5), long beams (6, 0.3, 0.3). Avoid 1x1x1 cubes.",
+    example: {
+      position: [0, 1.5, 0],
+      width: 2,
+      height: 3,
+      depth: 0.3,
+      material: { color: "#4488ff" },
+      physics: { mass: 1, isStatic: true, colliderType: "cuboid" },
+    },
+  },
+
+  GameSphere: {
+    props: z.object({
+      ...gameTransformShadowPhysicsDamage,
+      radius: z.number().nullable(),
+      widthSegments: z.number().nullable(),
+      heightSegments: z.number().nullable(),
+    }),
+    description:
+      "Sphere with optional physics and damage. Set radius explicitly (0.2 for small details, 1-3 for tree canopies, 0.5 for default).",
+    example: {
+      position: [0, 1.5, 0],
+      radius: 1.5,
+      material: { color: "#228B22" },
+      castShadow: true,
+    },
+  },
+
+  GameCylinder: {
+    props: z.object({
+      ...gameTransformShadowPhysicsDamage,
+      radiusTop: z.number().nullable(),
+      radiusBottom: z.number().nullable(),
+      height: z.number().nullable(),
+      radialSegments: z.number().nullable(),
+    }),
+    description:
+      "Cylinder with optional physics and damage. Set radiusTop/radiusBottom to control thickness and height for tall/thin shapes (e.g. tree trunk: radiusTop 0.15, radiusBottom 0.2, height 4).",
+    example: {
+      position: [0, 2, 0],
+      radiusTop: 0.15,
+      radiusBottom: 0.2,
+      height: 4,
+      material: { color: "#8B4513" },
+      castShadow: true,
+    },
+  },
+
+  GameCone: {
+    props: z.object({
+      ...gameTransformShadowPhysicsDamage,
+      radius: z.number().nullable(),
+      height: z.number().nullable(),
+      radialSegments: z.number().nullable(),
+    }),
+    description:
+      "Cone with optional physics and damage. Set radius and height for varied shapes (e.g. roof: radius 2.5, height 1.5).",
+    example: {
+      position: [0, 3.5, 0],
+      radius: 2.5,
+      height: 1.5,
+      material: { color: "#8B0000" },
+      castShadow: true,
+    },
+  },
+
+  GameTorus: {
+    props: z.object({
+      ...gameTransformShadowPhysicsDamage,
+      radius: z.number().nullable(),
+      tube: z.number().nullable(),
+      radialSegments: z.number().nullable(),
+      tubularSegments: z.number().nullable(),
+    }),
+    description:
+      "Torus (ring) with optional physics and damage. Set radius and tube to control ring size and thickness.",
+    example: {
+      position: [0, 1, 0],
+      radius: 1,
+      tube: 0.15,
+      material: { color: "#ff44ff" },
+    },
+  },
+
+  GamePlane: {
+    props: z.object({
+      ...gameTransformShadowPhysicsDamage,
+      width: z.number().nullable(),
+      height: z.number().nullable(),
+    }),
+    description:
+      "Plane with optional physics. Use for floors and walls. Rotate [-PI/2, 0, 0] for ground.",
+    example: {
+      position: [0, 0, 0],
+      rotation: [-1.5708, 0, 0],
+      scale: [10, 10, 1],
+      material: { color: "#888888" },
+      physics: { isStatic: true, colliderType: "cuboid" },
+    },
+  },
+
+  GameCapsule: {
+    props: z.object({
+      ...gameTransformShadowPhysicsDamage,
+      radius: z.number().nullable(),
+      length: z.number().nullable(),
+      capSegments: z.number().nullable(),
+      radialSegments: z.number().nullable(),
+    }),
+    description: "Capsule with optional physics and damage.",
+    example: {
+      position: [0, 1, 0],
+      material: { color: "#00cccc" },
+      physics: { mass: 1, colliderType: "capsule" },
+    },
+  },
+
+  GameKnot: {
+    props: z.object({
+      ...gameTransformShadowPhysicsDamage,
+      radius: z.number().nullable(),
+      tube: z.number().nullable(),
+      tubularSegments: z.number().nullable(),
+      radialSegments: z.number().nullable(),
+      p: z.number().nullable(),
+      q: z.number().nullable(),
+    }),
+    description: "Torus knot with optional physics and damage.",
+    example: {
+      position: [0, 1, 0],
+      radius: 1,
+      tube: 0.3,
+      material: { color: "#ff44ff" },
+    },
+  },
+
+  GameTetrahedron: {
+    props: z.object({
+      ...gameTransformShadowPhysicsDamage,
+      radius: z.number().nullable(),
+    }),
+    description: "Tetrahedron with optional physics and damage.",
+  },
+
+  GameOctahedron: {
+    props: z.object({
+      ...gameTransformShadowPhysicsDamage,
+      radius: z.number().nullable(),
+    }),
+    description: "Octahedron with optional physics and damage.",
+  },
+
+  GameDodecahedron: {
+    props: z.object({
+      ...gameTransformShadowPhysicsDamage,
+      radius: z.number().nullable(),
+    }),
+    description: "Dodecahedron with optional physics and damage.",
+  },
+
+  GameIcosahedron: {
+    props: z.object({
+      ...gameTransformShadowPhysicsDamage,
+      radius: z.number().nullable(),
+    }),
+    description: "Icosahedron with optional physics and damage.",
+  },
+
+  GameExtrude: {
+    props: z.object({
+      ...gameTransformShadowPhysicsDamage,
+      shapeData: z
+        .object({
+          points: z.array(z.tuple([z.number(), z.number()])),
+          holes: z.array(z.array(z.tuple([z.number(), z.number()]))).nullable(),
+        })
+        .nullable(),
+      depth: z.number().nullable(),
+    }),
+    description: "Extruded 2D shape into 3D with optional physics and damage.",
+    example: {
+      position: [0, 0.5, 0],
+      shapeData: {
+        points: [
+          [-0.5, -0.5],
+          [0.5, -0.5],
+          [0.5, 0.5],
+          [-0.5, 0.5],
+        ],
+      },
+      depth: 1,
+      material: { color: "#ff8800" },
+    },
+  },
+
+  GameTube: {
+    props: z.object({
+      ...gameTransformShadowPhysicsDamage,
+      radius: z.number().nullable(),
+      tubularSegments: z.number().nullable(),
+      radialSegments: z.number().nullable(),
+    }),
+    description: "Tube geometry following a curve with optional physics.",
+    example: {
+      position: [0, 0.5, 0],
+      radius: 0.1,
+      material: { color: "#00ccff" },
+    },
+  },
+
+  GameShape: {
+    props: z.object({
+      ...gameTransformShadowPhysicsDamage,
+      shapeData: z
+        .object({
+          points: z.array(z.tuple([z.number(), z.number()])),
+          holes: z.array(z.array(z.tuple([z.number(), z.number()]))).nullable(),
+        })
+        .nullable(),
+    }),
+    description:
+      "2D shape geometry (flat polygon) with optional physics and damage.",
+    example: {
+      position: [0, 0.5, 0],
+      shapeData: {
+        points: [
+          [-0.5, -0.5],
+          [0.5, -0.5],
+          [0, 0.5],
+        ],
+      },
+      material: { color: "#ff00ff" },
+    },
+  },
+
+  GameMesh: {
+    props: z.object({
+      ...gameTransformShadowPhysicsDamage,
+      meshData: z
+        .object({
+          vertices: z.array(z.number()),
+          indices: z.array(z.number()),
+          normals: z.array(z.number()).nullable(),
+          uvs: z.array(z.number()).nullable(),
+        })
+        .nullable(),
+    }),
+    description:
+      "Custom mesh with raw vertex data. Specify vertices, indices, normals, and UVs.",
+    example: {
+      position: [0, 0.5, 0],
+      meshData: {
+        vertices: [-0.5, -0.5, 0, 0.5, -0.5, 0, 0, 0.5, 0],
+        indices: [0, 1, 2],
+      },
+      material: { color: "#88ff00" },
+    },
+  },
+
+  // Unified light component
+  GameLight: {
+    props: z.object({
+      ...transformProps,
+      lightType: z.enum(["ambient", "directional", "point", "spot"]),
+      color: z.string().nullable(),
+      intensity: z.number().nullable(),
+      distance: z.number().nullable(),
+      decay: z.number().nullable(),
+      angle: z.number().nullable(),
+      penumbra: z.number().nullable(),
+      castShadow: z.boolean().nullable(),
+      objectId: z.string().nullable(),
+    }),
+    description:
+      "Unified light component. Set lightType to ambient, directional, point, or spot.",
+    example: {
+      lightType: "directional",
+      position: [5, 10, 5],
+      intensity: 1,
+      castShadow: true,
+    },
+  },
+
+  // Player controller
+  Player: {
+    props: z.object({
+      ...transformProps,
+      objectId: z.string().nullable(),
+      isPlayer: z.boolean().nullable(),
+    }),
+    description:
+      "Player spawn point. Enables first/third-person controls when playing.",
+    example: {
+      position: [0, 0, 0],
+    },
+  },
+
+  // Character NPC
+  GameCharacter: {
+    props: z.object({
+      ...transformProps,
+      ...shadowProps,
+      modelUrl: z.string(),
+      role: z.string().nullable(),
+      physics: physicsSchema.nullable(),
+      objectId: z.string().nullable(),
+    }),
+    description:
+      "NPC character with GLTF model, proximity interaction, and AI dialogue.",
+    example: {
+      position: [3, 0, 0],
+      modelUrl: "/models/character.glb",
+      role: "village guard",
+    },
+  },
+
+  // Game model with physics
+  GameModel: {
+    props: z.object({
+      ...transformProps,
+      ...shadowProps,
+      url: z.string(),
+      physics: physicsSchema.nullable(),
+      damage: damageSchema.nullable(),
+      objectId: z.string().nullable(),
+    }),
+    description: "GLTF/GLB 3D model with optional physics and damage.",
+    example: {
+      url: "/models/crate.glb",
+      position: [0, 0, 0],
+      physics: { isStatic: true, colliderType: "cuboid" },
+    },
+  },
+
+  // Sound emitter
+  SoundEmitter: {
+    props: z.object({
+      ...transformProps,
+      url: z.string(),
+      loop: z.boolean().nullable(),
+      volume: z.number().nullable(),
+      positional: z.boolean().nullable(),
+      distance: z.number().nullable(),
+      objectId: z.string().nullable(),
+    }),
+    description: "Positional or global audio emitter.",
+    example: {
+      position: [0, 1, 0],
+      url: "/sounds/ambient.mp3",
+      loop: true,
+      positional: true,
+    },
+  },
+
+  // Media plane
+  MediaPlane: {
+    props: z.object({
+      ...transformProps,
+      ...shadowProps,
+      url: z.string(),
+      mediaType: z.enum(["image", "video"]),
+      loop: z.boolean().nullable(),
+      autoplay: z.boolean().nullable(),
+      muted: z.boolean().nullable(),
+      width: z.number().nullable(),
+      height: z.number().nullable(),
+      objectId: z.string().nullable(),
+    }),
+    description: "Image or video displayed on a 3D plane.",
+    example: {
+      position: [0, 2, -3],
+      url: "/images/poster.jpg",
+      mediaType: "image",
+      width: 3,
+      height: 2,
+    },
+  },
+
+  // Ground plane (physics-enabled floor)
+  GroundPlane: {
+    props: z.object({
+      ...transformProps,
+      material: materialSchema.nullable(),
+      size: z.number().nullable(),
+    }),
+    description: "Physics-enabled ground plane.",
+    example: {
+      position: [0, -0.1, 0],
+      size: 5000,
+      material: { color: "#4CAF50" },
+    },
+  },
+};
+
+export type GameComponentDefinitions = typeof gameComponentDefinitions;
+
+export const catalog = defineCatalog(schema, {
+  components: gameComponentDefinitions,
+  actions: {},
+});

+ 289 - 0
examples/game-engine/lib/defaults.ts

@@ -0,0 +1,289 @@
+import { v4 as uuidv4 } from "uuid";
+import type {
+  SceneObject,
+  Scene,
+  SceneSettings,
+  EnvironmentSettings,
+  ObjectType,
+} from "./types";
+
+export function createDefaultGrass(): SceneObject {
+  return {
+    id: uuidv4(),
+    name: "Grass",
+    description: "Large grass plane",
+    type: "plane",
+    position: [0, -0.1, 0],
+    rotation: [-Math.PI / 2, 0, 0],
+    scale: [5000, 5000, 1],
+    material: {
+      color: "#4CAF50",
+      metalness: 0.0,
+      roughness: 0.9,
+      emissive: "#000000",
+      emissiveIntensity: 0,
+    },
+    physics: {
+      mass: 0,
+      isStatic: true,
+      restitution: 0.2,
+      friction: 0.8,
+      colliderType: "cuboid",
+    },
+    visible: true,
+  };
+}
+
+export function createDefaultAmbientLight(): SceneObject {
+  return {
+    id: uuidv4(),
+    name: "Ambient Light",
+    description: "Default ambient light",
+    type: "light",
+    position: [0, 3, 0],
+    rotation: [0, 0, 0],
+    scale: [1, 1, 1],
+    material: {
+      color: "#ffffff",
+      metalness: 0,
+      roughness: 1,
+      emissive: "#000000",
+      emissiveIntensity: 0,
+    },
+    physics: {
+      mass: 1,
+      isStatic: false,
+      restitution: 0.2,
+      friction: 0.5,
+      colliderType: "none",
+    },
+    visible: true,
+    intensity: 0.5,
+    lightType: "ambient",
+  };
+}
+
+export function createDefaultDirectionalLight(): SceneObject {
+  return {
+    id: uuidv4(),
+    name: "Directional Light",
+    description: "Default directional light",
+    type: "light",
+    position: [10, 10, 10],
+    rotation: [0, 0, 0],
+    scale: [1, 1, 1],
+    material: {
+      color: "#ffffff",
+      metalness: 0,
+      roughness: 1,
+      emissive: "#000000",
+      emissiveIntensity: 0,
+    },
+    physics: {
+      mass: 1,
+      isStatic: false,
+      restitution: 0.2,
+      friction: 0.5,
+      colliderType: "none",
+    },
+    visible: true,
+    intensity: 1,
+    lightType: "directional",
+  };
+}
+
+export function createDefaultPlayer(): SceneObject {
+  return {
+    id: "player-" + uuidv4(),
+    name: "Player",
+    description: "Default player",
+    type: "player",
+    position: [0, 0, 0],
+    rotation: [0, 0, 0],
+    scale: [1, 1, 1],
+    material: {
+      color: "#3498db",
+      metalness: 0.1,
+      roughness: 0.5,
+      emissive: "#000000",
+      emissiveIntensity: 0,
+    },
+    physics: {
+      mass: 1,
+      isStatic: false,
+      restitution: 0.2,
+      friction: 0.5,
+      colliderType: "none",
+    },
+    visible: true,
+    isPlayer: true,
+  };
+}
+
+export function createDefaultSceneSettings(): SceneSettings {
+  return {
+    grid: {
+      visible: true,
+      size: 1,
+      divisions: 100,
+      color: "#444444",
+      secondaryColor: "#888888",
+      fadeDistance: 100,
+      fadeStrength: 1,
+    },
+    fog: {
+      enabled: false,
+      color: "#cccccc",
+      near: 1,
+      far: 50,
+    },
+  };
+}
+
+export function createDefaultEnvironmentSettings(): EnvironmentSettings {
+  return {
+    preset: "city",
+    customHdri: null,
+    intensity: 1,
+    blur: 0.5,
+  };
+}
+
+export function createDefaultScene(name = "Scene 1"): Scene {
+  return {
+    id: uuidv4(),
+    name,
+    description: "",
+    isDefault: true,
+    objects: [
+      createDefaultGrass(),
+      createDefaultAmbientLight(),
+      createDefaultDirectionalLight(),
+      createDefaultPlayer(),
+    ],
+    sceneSettings: createDefaultSceneSettings(),
+    environmentSettings: createDefaultEnvironmentSettings(),
+  };
+}
+
+export function createDefaultObject(
+  type: ObjectType,
+  id: string,
+  objectCount: number,
+): SceneObject {
+  const obj: SceneObject = {
+    id,
+    name: `${type.charAt(0).toUpperCase() + type.slice(1)} ${objectCount + 1}`,
+    description: "",
+    type,
+    position: [
+      0,
+      type === "box" ||
+      type === "cylinder" ||
+      type === "cone" ||
+      type === "torus"
+        ? 0.5
+        : 0,
+      0,
+    ],
+    rotation: [0, 0, 0],
+    scale: [1, 1, 1],
+    material: {
+      color:
+        type === "light"
+          ? "#ffffff"
+          : type === "player"
+            ? "#3498db"
+            : `#${Math.floor(Math.random() * 16777215)
+                .toString(16)
+                .padStart(6, "0")}`,
+      metalness: 0.1,
+      roughness: 0.5,
+      emissive: "#000000",
+      emissiveIntensity: 0,
+    },
+    physics: {
+      mass: 1,
+      isStatic: false,
+      restitution: 0.2,
+      friction: 0.5,
+      colliderType:
+        type === "sphere" ? "ball" : type === "capsule" ? "capsule" : "cuboid",
+    },
+    damage: {
+      amount: 0,
+      enabled: false,
+    },
+    visible: true,
+  };
+
+  if (
+    type === "capsule" ||
+    type === "extrude" ||
+    type === "tetrahedron" ||
+    type === "octahedron" ||
+    type === "dodecahedron" ||
+    type === "icosahedron" ||
+    type === "knot" ||
+    type === "tube" ||
+    type === "shape" ||
+    type === "mesh"
+  ) {
+    obj.position = [0, 0.5, 0];
+  }
+
+  if (type === "light") {
+    obj.intensity = 1;
+    obj.distance = 0;
+    obj.decay = 2;
+    obj.lightType = "point";
+    obj.physics.colliderType = "none";
+  }
+
+  if (type === "plane") {
+    obj.rotation = [-Math.PI / 2, 0, 0];
+  }
+
+  if (type === "player") {
+    obj.name = "Player";
+    obj.position = [0, 0, 0];
+    obj.physics.colliderType = "none";
+    obj.isPlayer = true;
+  }
+
+  if (type === "image" || type === "video") {
+    obj.physics.colliderType = "none";
+    obj.physics.isStatic = true;
+  }
+
+  if (type === "shape") {
+    obj.shapeData = {
+      points: [
+        [-0.5, -0.5],
+        [0.5, -0.5],
+        [0.5, 0.5],
+        [-0.5, 0.5],
+      ],
+      holes: [],
+    };
+  }
+
+  if (type === "mesh") {
+    obj.meshData = {
+      vertices: [
+        -0.5, -0.5, 0.5, 0.5, -0.5, 0.5, 0.5, 0.5, 0.5, -0.5, 0.5, 0.5, -0.5,
+        -0.5, -0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, 0.5, -0.5, -0.5, -0.5, 0.5,
+        -0.5, -0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, -0.5, -0.5, -0.5, -0.5,
+        0.5, -0.5, -0.5, 0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, -0.5, 0.5,
+        0.5, -0.5, 0.5, 0.5, 0.5, 0.5, -0.5, 0.5, -0.5, -0.5, -0.5, -0.5, -0.5,
+        0.5, -0.5, 0.5, 0.5, -0.5, 0.5, -0.5,
+      ],
+      indices: [
+        0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7, 8, 9, 10, 8, 10, 11, 12, 13, 14, 12,
+        14, 15, 16, 17, 18, 16, 18, 19, 20, 21, 22, 20, 22, 23,
+      ],
+    };
+  }
+
+  return obj;
+}

+ 53 - 0
examples/game-engine/lib/rate-limit.ts

@@ -0,0 +1,53 @@
+import { Ratelimit } from "@upstash/ratelimit";
+import { Redis } from "@upstash/redis";
+
+let _minuteRateLimit: Ratelimit | null = null;
+let _dailyRateLimit: Ratelimit | null = null;
+
+function getRedis(): Redis | null {
+  const url = process.env.KV_REST_API_URL;
+  const token = process.env.KV_REST_API_TOKEN;
+
+  if (!url || !token) {
+    return null;
+  }
+
+  return new Redis({ url, token });
+}
+
+const noopRateLimiter = {
+  limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
+};
+
+const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
+const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
+
+export const minuteRateLimit = {
+  limit: async (identifier: string) => {
+    if (!_minuteRateLimit) {
+      const redis = getRedis();
+      if (!redis) return noopRateLimiter.limit();
+      _minuteRateLimit = new Ratelimit({
+        redis,
+        limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
+        prefix: "ratelimit:game-engine:minute",
+      });
+    }
+    return _minuteRateLimit.limit(identifier);
+  },
+};
+
+export const dailyRateLimit = {
+  limit: async (identifier: string) => {
+    if (!_dailyRateLimit) {
+      const redis = getRedis();
+      if (!redis) return noopRateLimiter.limit();
+      _dailyRateLimit = new Ratelimit({
+        redis,
+        limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
+        prefix: "ratelimit:game-engine:daily",
+      });
+    }
+    return _dailyRateLimit.limit(identifier);
+  },
+};

+ 89 - 0
examples/game-engine/lib/registry.tsx

@@ -0,0 +1,89 @@
+/* eslint-disable @typescript-eslint/ban-ts-comment */
+// @ts-nocheck
+import { defineRegistry } from "@json-render/react";
+import { threeComponents } from "@json-render/react-three-fiber";
+import { catalog } from "./catalog";
+import {
+  GameBox,
+  GameSphere,
+  GameCylinder,
+  GameCone,
+  GameTorus,
+  GamePlane,
+  GameCapsule,
+  GameKnot,
+  GameTetrahedron,
+  GameOctahedron,
+  GameDodecahedron,
+  GameIcosahedron,
+  GameExtrude,
+  GameTube,
+  GameShape,
+  GameMesh,
+} from "@/components/game/game-primitives";
+import { GameLight } from "@/components/game/game-light";
+import { Player } from "@/components/game/player";
+import { GameCharacter } from "@/components/game/character";
+import { SoundEmitter } from "@/components/game/sound-emitter";
+import { MediaPlane } from "@/components/game/media-plane";
+import { GroundPlane } from "@/components/game/ground-plane";
+import { GameModel as GameModelComponent } from "@/components/game/model-wrapper";
+
+const gameComponents = {
+  GameBox: ({ props, children }) => <GameBox {...props}>{children}</GameBox>,
+  GameSphere: ({ props, children }) => (
+    <GameSphere {...props}>{children}</GameSphere>
+  ),
+  GameCylinder: ({ props, children }) => (
+    <GameCylinder {...props}>{children}</GameCylinder>
+  ),
+  GameCone: ({ props, children }) => <GameCone {...props}>{children}</GameCone>,
+  GameTorus: ({ props, children }) => (
+    <GameTorus {...props}>{children}</GameTorus>
+  ),
+  GamePlane: ({ props, children }) => (
+    <GamePlane {...props}>{children}</GamePlane>
+  ),
+  GameCapsule: ({ props, children }) => (
+    <GameCapsule {...props}>{children}</GameCapsule>
+  ),
+  GameKnot: ({ props, children }) => <GameKnot {...props}>{children}</GameKnot>,
+  GameTetrahedron: ({ props, children }) => (
+    <GameTetrahedron {...props}>{children}</GameTetrahedron>
+  ),
+  GameOctahedron: ({ props, children }) => (
+    <GameOctahedron {...props}>{children}</GameOctahedron>
+  ),
+  GameDodecahedron: ({ props, children }) => (
+    <GameDodecahedron {...props}>{children}</GameDodecahedron>
+  ),
+  GameIcosahedron: ({ props, children }) => (
+    <GameIcosahedron {...props}>{children}</GameIcosahedron>
+  ),
+  GameExtrude: ({ props, children }) => (
+    <GameExtrude {...props}>{children}</GameExtrude>
+  ),
+  GameTube: ({ props, children }) => <GameTube {...props}>{children}</GameTube>,
+  GameShape: ({ props, children }) => (
+    <GameShape {...props}>{children}</GameShape>
+  ),
+  GameMesh: ({ props, children }) => <GameMesh {...props}>{children}</GameMesh>,
+  GameLight: ({ props }) => <GameLight {...props} />,
+  Player: ({ props }) => <Player {...props} />,
+  GameCharacter: ({ props }) => <GameCharacter {...props} />,
+  GameModel: ({ props }) => <GameModelComponent {...props} />,
+  SoundEmitter: ({ props }) => <SoundEmitter {...props} />,
+  MediaPlane: ({ props }) => <MediaPlane {...props} />,
+  GroundPlane: ({ props }) => <GroundPlane {...props} />,
+};
+
+const allComponents = {
+  ...threeComponents,
+  ...gameComponents,
+};
+
+export const { registry } = defineRegistry(catalog, {
+  components: allComponents,
+});
+
+export { catalog };

+ 224 - 0
examples/game-engine/lib/scene-to-spec.ts

@@ -0,0 +1,224 @@
+import type { Scene, SceneObject } from "./types";
+
+interface SpecElement {
+  type: string;
+  props: Record<string, unknown>;
+  children: string[];
+}
+
+interface Spec {
+  root: string;
+  elements: Record<string, SpecElement>;
+}
+
+const primitiveTypeMap: Record<string, string> = {
+  box: "GameBox",
+  sphere: "GameSphere",
+  cylinder: "GameCylinder",
+  cone: "GameCone",
+  torus: "GameTorus",
+  plane: "GamePlane",
+  capsule: "GameCapsule",
+  knot: "GameKnot",
+  tetrahedron: "GameTetrahedron",
+  octahedron: "GameOctahedron",
+  dodecahedron: "GameDodecahedron",
+  icosahedron: "GameIcosahedron",
+  extrude: "GameExtrude",
+  tube: "GameTube",
+  shape: "GameShape",
+  mesh: "GameMesh",
+};
+
+function objectToSpecType(obj: SceneObject): string {
+  if (obj.type === "light") return "GameLight";
+  if (obj.type === "player") return "Player";
+  if (obj.type === "character") return "GameCharacter";
+  if (obj.type === "model") return "GameModel";
+  if (obj.type === "sound") return "SoundEmitter";
+  if (obj.type === "image" || obj.type === "video") return "MediaPlane";
+  return primitiveTypeMap[obj.type] || "GameBox";
+}
+
+function objectToProps(obj: SceneObject): Record<string, unknown> {
+  const props: Record<string, unknown> = {
+    position: obj.position,
+    rotation: obj.rotation,
+    scale: obj.scale,
+    objectId: obj.id,
+  };
+
+  const specType = objectToSpecType(obj);
+
+  if (specType === "GameLight") {
+    props.lightType = obj.lightType || "point";
+    props.color = obj.material.color;
+    props.intensity = obj.intensity ?? 1;
+    if (obj.distance) props.distance = obj.distance;
+    if (obj.decay) props.decay = obj.decay;
+    if (obj.angle) props.angle = obj.angle;
+    if (obj.penumbra) props.penumbra = obj.penumbra;
+    props.castShadow =
+      obj.lightType === "directional" || obj.lightType === "spot";
+    return props;
+  }
+
+  if (specType === "Player") {
+    props.isPlayer = true;
+    return props;
+  }
+
+  if (specType === "GameCharacter") {
+    props.modelUrl = obj.modelUrl || "";
+    props.role = obj.character?.role || "villager";
+    if (obj.physics.colliderType !== "none") {
+      props.physics = obj.physics;
+    }
+    props.castShadow = true;
+    props.receiveShadow = true;
+    return props;
+  }
+
+  if (specType === "GameModel") {
+    props.url = obj.modelUrl || "";
+    if (obj.physics.colliderType !== "none") {
+      props.physics = obj.physics;
+    }
+    if (obj.damage?.enabled) {
+      props.damage = obj.damage;
+    }
+    props.castShadow = true;
+    props.receiveShadow = true;
+    return props;
+  }
+
+  if (specType === "SoundEmitter") {
+    props.url = obj.sound?.url || "";
+    props.loop = obj.sound?.loop ?? false;
+    props.volume = obj.sound?.volume ?? 1;
+    props.positional = obj.sound?.positional ?? true;
+    props.distance = obj.sound?.distance ?? 10;
+    return props;
+  }
+
+  if (specType === "MediaPlane") {
+    props.url = obj.media?.url || "";
+    props.mediaType = obj.type === "video" ? "video" : "image";
+    props.loop = obj.media?.loop;
+    props.autoplay = obj.media?.autoplay;
+    props.muted = obj.media?.muted;
+    return props;
+  }
+
+  // Game primitives
+  props.material = obj.material;
+  if (obj.physics.colliderType !== "none") {
+    props.physics = obj.physics;
+  }
+  if (obj.damage?.enabled) {
+    props.damage = obj.damage;
+  }
+  if (obj.shapeData) {
+    props.shapeData = obj.shapeData;
+  }
+  if (obj.meshData) {
+    props.meshData = obj.meshData;
+  }
+
+  // Geometry props
+  if (obj.width != null) props.width = obj.width;
+  if (obj.height != null) props.height = obj.height;
+  if (obj.depth != null) props.depth = obj.depth;
+  if (obj.radius != null) props.radius = obj.radius;
+  if (obj.radiusTop != null) props.radiusTop = obj.radiusTop;
+  if (obj.radiusBottom != null) props.radiusBottom = obj.radiusBottom;
+  if (obj.radialSegments != null) props.radialSegments = obj.radialSegments;
+  if (obj.tube != null) props.tube = obj.tube;
+  if (obj.tubularSegments != null) props.tubularSegments = obj.tubularSegments;
+  if (obj.widthSegments != null) props.widthSegments = obj.widthSegments;
+  if (obj.heightSegments != null) props.heightSegments = obj.heightSegments;
+  if (obj.length != null) props.length = obj.length;
+  if (obj.p != null) props.p = obj.p;
+  if (obj.q != null) props.q = obj.q;
+
+  props.castShadow = true;
+  props.receiveShadow = true;
+
+  return props;
+}
+
+export function sceneToSpec(scene: Scene): Spec {
+  const elements: Record<string, SpecElement> = {};
+  const childIds: string[] = [];
+
+  // Add environment
+  elements["env"] = {
+    type: "Environment",
+    props: {
+      preset: scene.environmentSettings.preset,
+      background: true,
+      blur: scene.environmentSettings.blur,
+      intensity: scene.environmentSettings.intensity,
+    },
+    children: [],
+  };
+  childIds.push("env");
+
+  // Add fog if enabled
+  if (scene.sceneSettings.fog.enabled) {
+    elements["fog"] = {
+      type: "Fog",
+      props: {
+        color: scene.sceneSettings.fog.color,
+        near: scene.sceneSettings.fog.near,
+        far: scene.sceneSettings.fog.far,
+      },
+      children: [],
+    };
+    childIds.push("fog");
+  }
+
+  // Add grid if visible
+  if (scene.sceneSettings.grid.visible) {
+    elements["grid"] = {
+      type: "GridHelper",
+      props: {
+        position: [0, 0.01, 0],
+        size:
+          scene.sceneSettings.grid.size * scene.sceneSettings.grid.divisions,
+        divisions: scene.sceneSettings.grid.divisions,
+        color: scene.sceneSettings.grid.color,
+        secondaryColor: scene.sceneSettings.grid.secondaryColor,
+        fadeDistance: scene.sceneSettings.grid.fadeDistance,
+        fadeStrength: scene.sceneSettings.grid.fadeStrength,
+      },
+      children: [],
+    };
+    childIds.push("grid");
+  }
+
+  // Add scene objects
+  for (const obj of scene.objects) {
+    if (!obj.visible) continue;
+
+    const key = `obj-${obj.id}`;
+    elements[key] = {
+      type: objectToSpecType(obj),
+      props: objectToProps(obj),
+      children: [],
+    };
+    childIds.push(key);
+  }
+
+  // Root scene group
+  elements["scene"] = {
+    type: "Group",
+    props: { position: null, rotation: null, scale: null },
+    children: childIds,
+  };
+
+  return {
+    root: "scene",
+    elements,
+  };
+}

+ 243 - 0
examples/game-engine/lib/spec-to-scene.ts

@@ -0,0 +1,243 @@
+import { v4 as uuidv4 } from "uuid";
+import type { Spec } from "@json-render/core";
+import type {
+  SceneObject,
+  ObjectType,
+  LightType,
+  Material,
+  Physics,
+  ColliderType,
+} from "./types";
+
+const specTypeToObjectType: Record<string, ObjectType> = {
+  GameBox: "box",
+  GameSphere: "sphere",
+  GameCylinder: "cylinder",
+  GameCone: "cone",
+  GameTorus: "torus",
+  GamePlane: "plane",
+  GameCapsule: "capsule",
+  GameKnot: "knot",
+  GameTetrahedron: "tetrahedron",
+  GameOctahedron: "octahedron",
+  GameDodecahedron: "dodecahedron",
+  GameIcosahedron: "icosahedron",
+  GameExtrude: "extrude",
+  GameTube: "tube",
+  GameShape: "shape",
+  GameMesh: "mesh",
+  GameLight: "light",
+  Player: "player",
+  GameCharacter: "character",
+  GameModel: "model",
+  SoundEmitter: "sound",
+  MediaPlane: "image",
+  GroundPlane: "plane",
+};
+
+const DEFAULT_MATERIAL: Material = {
+  color: "#888888",
+  metalness: 0,
+  roughness: 0.5,
+  emissive: "#000000",
+  emissiveIntensity: 0,
+};
+
+const DEFAULT_PHYSICS: Physics = {
+  mass: 1,
+  isStatic: false,
+  restitution: 0.3,
+  friction: 0.5,
+  colliderType: "none",
+};
+
+function parseMaterial(raw: unknown): Material {
+  if (!raw || typeof raw !== "object") return { ...DEFAULT_MATERIAL };
+  const m = raw as Record<string, unknown>;
+  return {
+    color: (m.color as string) || DEFAULT_MATERIAL.color,
+    metalness: (m.metalness as number) ?? DEFAULT_MATERIAL.metalness,
+    roughness: (m.roughness as number) ?? DEFAULT_MATERIAL.roughness,
+    emissive: (m.emissive as string) || DEFAULT_MATERIAL.emissive,
+    emissiveIntensity:
+      (m.emissiveIntensity as number) ?? DEFAULT_MATERIAL.emissiveIntensity,
+  };
+}
+
+function parsePhysics(raw: unknown): Physics {
+  if (!raw || typeof raw !== "object") return { ...DEFAULT_PHYSICS };
+  const p = raw as Record<string, unknown>;
+  return {
+    mass: (p.mass as number) ?? DEFAULT_PHYSICS.mass,
+    isStatic: (p.isStatic as boolean) ?? DEFAULT_PHYSICS.isStatic,
+    restitution: (p.restitution as number) ?? DEFAULT_PHYSICS.restitution,
+    friction: (p.friction as number) ?? DEFAULT_PHYSICS.friction,
+    colliderType:
+      (p.colliderType as ColliderType) || DEFAULT_PHYSICS.colliderType,
+  };
+}
+
+function specElementToSceneObject(
+  key: string,
+  element: {
+    type: string;
+    props: Record<string, unknown>;
+    children?: string[];
+  },
+): SceneObject | null {
+  const objectType = specTypeToObjectType[element.type];
+  if (!objectType) return null;
+
+  const props = element.props;
+
+  const id = (props.objectId as string) || key.replace(/^obj-/, "") || uuidv4();
+
+  const obj: SceneObject = {
+    id,
+    name: (props.name as string) || element.type.replace(/^Game/, ""),
+    type: objectType,
+    position: (props.position as [number, number, number]) || [0, 0, 0],
+    rotation: (props.rotation as [number, number, number]) || [0, 0, 0],
+    scale: (props.scale as [number, number, number]) || [1, 1, 1],
+    material: parseMaterial(props.material),
+    physics: parsePhysics(props.physics),
+    visible: true,
+  };
+
+  if (props.damage && typeof props.damage === "object") {
+    const d = props.damage as Record<string, unknown>;
+    obj.damage = {
+      amount: (d.amount as number) ?? 0,
+      enabled: (d.enabled as boolean) ?? false,
+    };
+  }
+
+  if (element.type === "GameLight") {
+    obj.lightType = (props.lightType as LightType) || "point";
+    obj.intensity = (props.intensity as number) ?? 1;
+    if (props.color) obj.material.color = props.color as string;
+    if (props.distance != null) obj.distance = props.distance as number;
+    if (props.decay != null) obj.decay = props.decay as number;
+    if (props.angle != null) obj.angle = props.angle as number;
+    if (props.penumbra != null) obj.penumbra = props.penumbra as number;
+    obj.physics.colliderType = "none";
+  }
+
+  if (element.type === "Player") {
+    obj.isPlayer = true;
+    obj.physics.colliderType = "none";
+  }
+
+  if (element.type === "GameCharacter") {
+    obj.modelUrl = (props.modelUrl as string) || "";
+    obj.character = { role: (props.role as string) || "villager" };
+  }
+
+  if (element.type === "GameModel") {
+    obj.modelUrl = (props.url as string) || "";
+  }
+
+  if (element.type === "SoundEmitter") {
+    obj.sound = {
+      url: (props.url as string) || "",
+      loop: (props.loop as boolean) ?? false,
+      volume: (props.volume as number) ?? 1,
+      positional: (props.positional as boolean) ?? true,
+      distance: (props.distance as number) ?? 10,
+    };
+    obj.physics.colliderType = "none";
+  }
+
+  if (element.type === "MediaPlane") {
+    const mediaType = (props.mediaType as string) || "image";
+    obj.type = mediaType === "video" ? "video" : "image";
+    obj.media = {
+      url: (props.url as string) || "",
+      type: mediaType,
+      loop: props.loop as boolean | undefined,
+      autoplay: props.autoplay as boolean | undefined,
+      muted: props.muted as boolean | undefined,
+    };
+    obj.physics.colliderType = "none";
+  }
+
+  // Geometry props
+  if (props.width != null) obj.width = props.width as number;
+  if (props.height != null) obj.height = props.height as number;
+  if (props.depth != null) obj.depth = props.depth as number;
+  if (props.radius != null) obj.radius = props.radius as number;
+  if (props.radiusTop != null) obj.radiusTop = props.radiusTop as number;
+  if (props.radiusBottom != null)
+    obj.radiusBottom = props.radiusBottom as number;
+  if (props.radialSegments != null)
+    obj.radialSegments = props.radialSegments as number;
+  if (props.tube != null) obj.tube = props.tube as number;
+  if (props.tubularSegments != null)
+    obj.tubularSegments = props.tubularSegments as number;
+  if (props.widthSegments != null)
+    obj.widthSegments = props.widthSegments as number;
+  if (props.heightSegments != null)
+    obj.heightSegments = props.heightSegments as number;
+  if (props.length != null) obj.length = props.length as number;
+  if (props.p != null) obj.p = props.p as number;
+  if (props.q != null) obj.q = props.q as number;
+
+  if (props.shapeData && typeof props.shapeData === "object") {
+    obj.shapeData = props.shapeData as SceneObject["shapeData"];
+  }
+
+  if (props.meshData && typeof props.meshData === "object") {
+    obj.meshData = props.meshData as SceneObject["meshData"];
+  }
+
+  if (element.type === "GroundPlane") {
+    obj.material = parseMaterial(props.material);
+    obj.rotation = [-Math.PI / 2, 0, 0];
+    const size = (props.size as number) ?? 5000;
+    obj.scale = [size, size, 1];
+    obj.physics = {
+      mass: 0,
+      isStatic: true,
+      restitution: 0.2,
+      friction: 0.8,
+      colliderType: "cuboid",
+    };
+  }
+
+  return obj;
+}
+
+/**
+ * Convert a json-render spec back into SceneObject[].
+ * Extracts all elements whose keys start with "obj-".
+ * Uses the scene root's children for ordering, then appends any
+ * obj-* elements not yet in children (handles streaming where
+ * elements arrive before the scene children array is updated).
+ */
+export function specToSceneObjects(spec: Spec): SceneObject[] {
+  const sceneElement = spec.elements["scene"] || spec.elements[spec.root];
+  const objects: SceneObject[] = [];
+  const seen = new Set<string>();
+
+  const childKeys = sceneElement?.children || [];
+  for (const key of childKeys) {
+    if (!key.startsWith("obj-")) continue;
+    const element = spec.elements[key];
+    if (!element) continue;
+    seen.add(key);
+
+    const obj = specElementToSceneObject(key, element);
+    if (obj) objects.push(obj);
+  }
+
+  for (const key of Object.keys(spec.elements)) {
+    if (!key.startsWith("obj-") || seen.has(key)) continue;
+    const element = spec.elements[key];
+    if (!element) continue;
+
+    const obj = specElementToSceneObject(key, element);
+    if (obj) objects.push(obj);
+  }
+
+  return objects;
+}

+ 61 - 0
examples/game-engine/lib/speech-queue.ts

@@ -0,0 +1,61 @@
+interface SpeechRequest {
+  text: string;
+  voiceId: string;
+  resolve: (url: string) => void;
+  reject: (error: unknown) => void;
+}
+
+class SpeechQueue {
+  private queue: SpeechRequest[] = [];
+  private processing = 0;
+  private maxConcurrent = 1;
+
+  constructor() {
+    this.processQueue = this.processQueue.bind(this);
+  }
+
+  async add(text: string, voiceId: string): Promise<string> {
+    return new Promise((resolve, reject) => {
+      this.queue.push({ text, voiceId, resolve, reject });
+      this.processQueue();
+    });
+  }
+
+  private async processQueue() {
+    if (this.processing >= this.maxConcurrent || this.queue.length === 0) {
+      return;
+    }
+
+    const request = this.queue.shift();
+    if (!request) return;
+
+    this.processing++;
+
+    try {
+      const response = await fetch("/api/text-to-speech", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          text: request.text,
+          voiceId: request.voiceId,
+        }),
+      });
+
+      if (!response.ok) {
+        const errorData = await response.json();
+        throw new Error(errorData.error || "Failed to generate speech");
+      }
+
+      const data = await response.json();
+      request.resolve(data.audioUrl);
+    } catch (error) {
+      request.reject(error);
+    } finally {
+      this.processing--;
+      this.processQueue();
+    }
+  }
+}
+
+const speechQueue = new SpeechQueue();
+export default speechQueue;

+ 590 - 0
examples/game-engine/lib/store.ts

@@ -0,0 +1,590 @@
+import { create } from "zustand";
+import { v4 as uuidv4 } from "uuid";
+import type {
+  EditorState,
+  Scene,
+  SceneObject,
+  ObjectType,
+  TransformMode,
+  ViewMode,
+} from "./types";
+import {
+  createDefaultScene,
+  createDefaultObject,
+  createDefaultGrass,
+  createDefaultAmbientLight,
+  createDefaultPlayer,
+  createDefaultSceneSettings,
+  createDefaultEnvironmentSettings,
+} from "./defaults";
+
+function saveHistory(state: EditorState): Partial<EditorState> {
+  return {
+    history: {
+      past: [...state.history.past.slice(-49), state.scenes],
+      future: [],
+    },
+    canUndo: true,
+    canRedo: false,
+  };
+}
+
+const initialScene = createDefaultScene();
+
+export const useEditorStore = create<EditorState>((set, get) => ({
+  scenes: [initialScene],
+  activeSceneId: initialScene.id,
+  selectedObjectId: null,
+  transformMode: "translate" as TransformMode,
+  isPlaying: false,
+  isLoading: false,
+  viewMode: "first-person" as ViewMode,
+  isPromptOpen: false,
+
+  health: 100,
+  shield: 50,
+  maxHealth: 100,
+  maxShield: 100,
+  lastDamageTime: null,
+  damageCooldown: 0.5,
+
+  history: { past: [], future: [] },
+  canUndo: false,
+  canRedo: false,
+
+  get activeScene() {
+    const state = get();
+    return (
+      state.scenes.find((s) => s.id === state.activeSceneId) || state.scenes[0]
+    );
+  },
+  get activeSceneObjects() {
+    const state = get();
+    const scene =
+      state.scenes.find((s) => s.id === state.activeSceneId) || state.scenes[0];
+    return scene ? scene.objects : [];
+  },
+
+  // Scene actions
+  createScene: (name = "New Scene") =>
+    set((state) => {
+      const newSceneId = uuidv4();
+      const newScene: Scene = {
+        id: newSceneId,
+        name,
+        description: "",
+        isDefault: state.scenes.length === 0,
+        objects: [
+          createDefaultGrass(),
+          createDefaultAmbientLight(),
+          createDefaultPlayer(),
+        ],
+        sceneSettings: createDefaultSceneSettings(),
+        environmentSettings: createDefaultEnvironmentSettings(),
+      };
+      return {
+        scenes: [...state.scenes, newScene],
+        activeSceneId: newSceneId,
+        selectedObjectId: null,
+      };
+    }),
+
+  deleteScene: (id) =>
+    set((state) => {
+      if (state.scenes.length <= 1) return state;
+      const updated = state.scenes.filter((s) => s.id !== id);
+      const deletedWasDefault = state.scenes.find(
+        (s) => s.id === id,
+      )?.isDefault;
+      if (deletedWasDefault && updated[0]) updated[0].isDefault = true;
+      return {
+        scenes: updated,
+        activeSceneId:
+          state.activeSceneId === id ? updated[0]!.id : state.activeSceneId,
+        selectedObjectId: null,
+      };
+    }),
+
+  duplicateScene: (id) =>
+    set((state) => {
+      const src = state.scenes.find((s) => s.id === id);
+      if (!src) return state;
+      const newId = uuidv4();
+      const clone: Scene = {
+        ...JSON.parse(JSON.stringify(src)),
+        id: newId,
+        name: `${src.name} (Copy)`,
+        isDefault: false,
+      };
+      return {
+        scenes: [...state.scenes, clone],
+        activeSceneId: newId,
+        selectedObjectId: null,
+      };
+    }),
+
+  setActiveScene: (id) =>
+    set((state) => {
+      if (!state.scenes.some((s) => s.id === id)) return state;
+      return { activeSceneId: id, selectedObjectId: null };
+    }),
+
+  updateSceneName: (id, name) =>
+    set((state) => ({
+      scenes: state.scenes.map((s) => (s.id === id ? { ...s, name } : s)),
+    })),
+
+  updateSceneDescription: (id, description) =>
+    set((state) => ({
+      scenes: state.scenes.map((s) =>
+        s.id === id ? { ...s, description } : s,
+      ),
+    })),
+
+  // Object actions
+  addObject: (type: ObjectType) =>
+    set((state) => {
+      const scene = state.scenes.find((s) => s.id === state.activeSceneId);
+      if (!scene) return state;
+      if (type === "player" && scene.objects.some((o) => o.type === "player"))
+        return state;
+
+      const id = uuidv4();
+      const newObj = createDefaultObject(type, id, scene.objects.length);
+
+      if (type === "light") {
+        const lightCount = scene.objects.filter(
+          (o) => o.type === "light",
+        ).length;
+        if (lightCount === 0) {
+          newObj.name = "Ambient Light";
+          newObj.intensity = 0.5;
+          newObj.position = [0, 3, 0];
+          newObj.lightType = "ambient";
+        } else if (lightCount === 1) {
+          newObj.name = "Directional Light";
+          newObj.intensity = 1;
+          newObj.position = [10, 10, 10];
+          newObj.lightType = "directional";
+        } else {
+          newObj.name = "Point Light";
+          newObj.intensity = 1;
+          newObj.distance = 10;
+          newObj.lightType = "point";
+        }
+      }
+
+      const updatedScenes = state.scenes.map((s) =>
+        s.id === state.activeSceneId
+          ? { ...s, objects: [...s.objects, newObj] }
+          : s,
+      );
+      return {
+        ...saveHistory({ ...state, scenes: updatedScenes }),
+        scenes: updatedScenes,
+        selectedObjectId: id,
+      };
+    }),
+
+  createCustomObject: (type: ObjectType, properties: Partial<SceneObject>) =>
+    set((state) => {
+      const scene = state.scenes.find((s) => s.id === state.activeSceneId);
+      if (!scene) return state;
+      if (type === "player" && scene.objects.some((o) => o.type === "player"))
+        return state;
+
+      const id = uuidv4();
+      const base = createDefaultObject(type, id, scene.objects.length);
+      const newObj: SceneObject = {
+        ...base,
+        ...properties,
+        id,
+        type,
+        material: { ...base.material, ...(properties.material || {}) },
+        physics: { ...base.physics, ...(properties.physics || {}) },
+      };
+
+      if (type === "light" && properties.lightType) {
+        newObj.lightType = properties.lightType;
+        if (
+          properties.lightType === "ambient" &&
+          properties.intensity === undefined
+        )
+          newObj.intensity = 0.5;
+        if (properties.lightType === "spot" && properties.angle === undefined)
+          newObj.angle = Math.PI / 3;
+        if (
+          properties.lightType === "spot" &&
+          properties.penumbra === undefined
+        )
+          newObj.penumbra = 0;
+      }
+
+      if (type === "plane") {
+        newObj.scale[2] = 1;
+        if (!properties.rotation) newObj.rotation = [-Math.PI / 2, 0, 0];
+      }
+
+      const updatedScenes = state.scenes.map((s) =>
+        s.id === state.activeSceneId
+          ? { ...s, objects: [...s.objects, newObj] }
+          : s,
+      );
+      return {
+        ...saveHistory({ ...state, scenes: updatedScenes }),
+        scenes: updatedScenes,
+        selectedObjectId: id,
+      };
+    }),
+
+  removeObject: (id) =>
+    set((state) => {
+      const updatedScenes = state.scenes.map((s) =>
+        s.id === state.activeSceneId
+          ? { ...s, objects: s.objects.filter((o) => o.id !== id) }
+          : s,
+      );
+      return {
+        ...saveHistory({ ...state, scenes: updatedScenes }),
+        scenes: updatedScenes,
+        selectedObjectId:
+          state.selectedObjectId === id ? null : state.selectedObjectId,
+      };
+    }),
+
+  replaceSceneObjects: (objects: SceneObject[]) =>
+    set((state) => {
+      const updatedScenes = state.scenes.map((s) =>
+        s.id === state.activeSceneId ? { ...s, objects } : s,
+      );
+      const selectionStillExists =
+        state.selectedObjectId != null &&
+        objects.some((o) => o.id === state.selectedObjectId);
+      return {
+        ...saveHistory({ ...state, scenes: updatedScenes }),
+        scenes: updatedScenes,
+        selectedObjectId: selectionStillExists ? state.selectedObjectId : null,
+      };
+    }),
+
+  initializePlayer: () =>
+    set((state) => {
+      const scene = state.scenes.find((s) => s.id === state.activeSceneId);
+      if (!scene || scene.objects.some((o) => o.type === "player"))
+        return state;
+      const player = createDefaultPlayer();
+      const updatedScenes = state.scenes.map((s) =>
+        s.id === state.activeSceneId
+          ? { ...s, objects: [...s.objects, player] }
+          : s,
+      );
+      return { scenes: updatedScenes };
+    }),
+
+  // Updates
+  selectObject: (id) => set({ selectedObjectId: id }),
+
+  updateObjectTransform: (id, transform) =>
+    set((state) => {
+      const updatedScenes = state.scenes.map((s) => {
+        if (s.id !== state.activeSceneId) return s;
+        return {
+          ...s,
+          objects: s.objects.map((o) => {
+            if (o.id !== id) return o;
+            const t = { ...transform };
+            if (o.type === "plane" && t.scale)
+              (t.scale as [number, number, number])[2] = 1;
+            return { ...o, ...t };
+          }),
+        };
+      });
+      return { scenes: updatedScenes };
+    }),
+
+  updateObjectMaterial: (id, material) =>
+    set((state) => {
+      const updatedScenes = state.scenes.map((s) => {
+        if (s.id !== state.activeSceneId) return s;
+        return {
+          ...s,
+          objects: s.objects.map((o) =>
+            o.id === id
+              ? { ...o, material: { ...o.material, ...material } }
+              : o,
+          ),
+        };
+      });
+      return {
+        ...saveHistory({ ...state, scenes: updatedScenes }),
+        scenes: updatedScenes,
+      };
+    }),
+
+  updateObjectPhysics: (id, physics) =>
+    set((state) => {
+      const updatedScenes = state.scenes.map((s) => {
+        if (s.id !== state.activeSceneId) return s;
+        return {
+          ...s,
+          objects: s.objects.map((o) =>
+            o.id === id ? { ...o, physics: { ...o.physics, ...physics } } : o,
+          ),
+        };
+      });
+      return {
+        ...saveHistory({ ...state, scenes: updatedScenes }),
+        scenes: updatedScenes,
+      };
+    }),
+
+  updateCharacter: (id, character) =>
+    set((state) => {
+      const updatedScenes = state.scenes.map((s) => {
+        if (s.id !== state.activeSceneId) return s;
+        return {
+          ...s,
+          objects: s.objects.map((o) =>
+            o.id === id
+              ? {
+                  ...o,
+                  character: { ...(o.character || { role: "" }), ...character },
+                }
+              : o,
+          ),
+        };
+      });
+      return {
+        ...saveHistory({ ...state, scenes: updatedScenes }),
+        scenes: updatedScenes,
+      };
+    }),
+
+  updateSound: (id, soundUpdates) =>
+    set((state) => {
+      const updatedScenes = state.scenes.map((s) => {
+        if (s.id !== state.activeSceneId) return s;
+        return {
+          ...s,
+          objects: s.objects.map((o) => {
+            if (o.id !== id) return o;
+            const current = o.sound || {
+              url: "",
+              loop: false,
+              volume: 1,
+              positional: true,
+              distance: 10,
+            };
+            return { ...o, sound: { ...current, ...soundUpdates } };
+          }),
+        };
+      });
+      return {
+        ...saveHistory({ ...state, scenes: updatedScenes }),
+        scenes: updatedScenes,
+      };
+    }),
+
+  updateMedia: (id, mediaUpdates) =>
+    set((state) => {
+      const updatedScenes = state.scenes.map((s) => {
+        if (s.id !== state.activeSceneId) return s;
+        return {
+          ...s,
+          objects: s.objects.map((o) => {
+            if (o.id !== id) return o;
+            const current = o.media || {
+              url: "",
+              type: "image",
+              loop: false,
+              autoplay: false,
+              muted: true,
+            };
+            return { ...o, media: { ...current, ...mediaUpdates } };
+          }),
+        };
+      });
+      return {
+        ...saveHistory({ ...state, scenes: updatedScenes }),
+        scenes: updatedScenes,
+      };
+    }),
+
+  updateDamage: (id, damageUpdates) =>
+    set((state) => {
+      const updatedScenes = state.scenes.map((s) => {
+        if (s.id !== state.activeSceneId) return s;
+        return {
+          ...s,
+          objects: s.objects.map((o) => {
+            if (o.id !== id) return o;
+            const current = o.damage || { amount: 0, enabled: false };
+            return { ...o, damage: { ...current, ...damageUpdates } };
+          }),
+        };
+      });
+      return {
+        ...saveHistory({ ...state, scenes: updatedScenes }),
+        scenes: updatedScenes,
+      };
+    }),
+
+  updateSceneSettings: (settings) =>
+    set((state) => {
+      const updatedScenes = state.scenes.map((s) => {
+        if (s.id !== state.activeSceneId) return s;
+        return {
+          ...s,
+          sceneSettings: {
+            ...s.sceneSettings,
+            ...settings,
+            grid: { ...s.sceneSettings.grid, ...(settings.grid || {}) },
+            fog: { ...s.sceneSettings.fog, ...(settings.fog || {}) },
+          },
+        };
+      });
+      return {
+        ...saveHistory({ ...state, scenes: updatedScenes }),
+        scenes: updatedScenes,
+      };
+    }),
+
+  updateEnvironmentSettings: (settings) =>
+    set((state) => {
+      const updatedScenes = state.scenes.map((s) => {
+        if (s.id !== state.activeSceneId) return s;
+        return {
+          ...s,
+          environmentSettings: { ...s.environmentSettings, ...settings },
+        };
+      });
+      return {
+        ...saveHistory({ ...state, scenes: updatedScenes }),
+        scenes: updatedScenes,
+      };
+    }),
+
+  updateObjectGeneral: (id, updates) =>
+    set((state) => {
+      const updatedScenes = state.scenes.map((s) => {
+        if (s.id !== state.activeSceneId) return s;
+        return {
+          ...s,
+          objects: s.objects.map((o) => {
+            if (o.id !== id) return o;
+            if (o.type === "plane" && updates.scale) updates.scale[2] = 1;
+            return { ...o, ...updates };
+          }),
+        };
+      });
+      return {
+        ...saveHistory({ ...state, scenes: updatedScenes }),
+        scenes: updatedScenes,
+      };
+    }),
+
+  toggleObjectVisibility: (id) =>
+    set((state) => {
+      const updatedScenes = state.scenes.map((s) => {
+        if (s.id !== state.activeSceneId) return s;
+        return {
+          ...s,
+          objects: s.objects.map((o) =>
+            o.id === id ? { ...o, visible: !o.visible } : o,
+          ),
+        };
+      });
+      return {
+        ...saveHistory({ ...state, scenes: updatedScenes }),
+        scenes: updatedScenes,
+      };
+    }),
+
+  // UI state
+  setTransformMode: (mode) => set({ transformMode: mode }),
+  setIsPlaying: (playing) =>
+    set((state) => ({
+      isPlaying: playing,
+      selectedObjectId: playing ? null : state.selectedObjectId,
+      health: playing ? state.maxHealth : state.health,
+      shield: playing ? state.maxShield : state.shield,
+      isPromptOpen: playing ? false : state.isPromptOpen,
+    })),
+  setIsLoading: (loading) => set({ isLoading: loading }),
+  setViewMode: (mode) => set({ viewMode: mode }),
+  setIsPromptOpen: (open) => set({ isPromptOpen: open }),
+
+  // History
+  saveToHistory: () => set((state) => saveHistory(state)),
+
+  undo: () =>
+    set((state) => {
+      if (state.history.past.length === 0) return state;
+      const prev = state.history.past[state.history.past.length - 1]!;
+      return {
+        scenes: prev,
+        history: {
+          past: state.history.past.slice(0, -1),
+          future: [state.scenes, ...state.history.future],
+        },
+        canUndo: state.history.past.length > 1,
+        canRedo: true,
+      };
+    }),
+
+  redo: () =>
+    set((state) => {
+      if (state.history.future.length === 0) return state;
+      const next = state.history.future[0]!;
+      return {
+        scenes: next,
+        history: {
+          past: [...state.history.past, state.scenes],
+          future: state.history.future.slice(1),
+        },
+        canUndo: true,
+        canRedo: state.history.future.length > 1,
+      };
+    }),
+
+  // Health
+  setHealth: (health) =>
+    set((state) => ({
+      health: Math.max(0, Math.min(health, state.maxHealth)),
+    })),
+  setShield: (shield) =>
+    set((state) => ({
+      shield: Math.max(0, Math.min(shield, state.maxShield)),
+    })),
+  takeDamage: (amount) =>
+    set((state) => {
+      const now = Date.now();
+      if (
+        state.lastDamageTime &&
+        now - state.lastDamageTime < state.damageCooldown * 1000
+      )
+        return state;
+
+      let remainingShield = state.shield;
+      let remainingHealth = state.health;
+      let remaining = amount;
+
+      if (remainingShield > 0) {
+        if (remainingShield >= remaining) {
+          remainingShield -= remaining;
+          remaining = 0;
+        } else {
+          remaining -= remainingShield;
+          remainingShield = 0;
+        }
+      }
+      if (remaining > 0)
+        remainingHealth = Math.max(0, remainingHealth - remaining);
+
+      return {
+        shield: remainingShield,
+        health: remainingHealth,
+        lastDamageTime: now,
+      };
+    }),
+}));

+ 8 - 0
examples/game-engine/lib/touch-state.ts

@@ -0,0 +1,8 @@
+export const touchMoveState = {
+  forward: 0,
+  right: 0,
+  jump: false,
+  sprint: false,
+  lookDeltaX: 0,
+  lookDeltaY: 0,
+};

+ 246 - 0
examples/game-engine/lib/types.ts

@@ -0,0 +1,246 @@
+export type ObjectType =
+  | "box"
+  | "sphere"
+  | "cylinder"
+  | "cone"
+  | "torus"
+  | "plane"
+  | "capsule"
+  | "tetrahedron"
+  | "octahedron"
+  | "dodecahedron"
+  | "icosahedron"
+  | "knot"
+  | "tube"
+  | "extrude"
+  | "shape"
+  | "mesh"
+  | "light"
+  | "model"
+  | "character"
+  | "sound"
+  | "player"
+  | "image"
+  | "video";
+
+export type TransformMode = "select" | "translate" | "rotate" | "scale";
+
+export type ColliderType = "cuboid" | "ball" | "capsule" | "none";
+
+export type LightType = "ambient" | "directional" | "point" | "spot";
+
+export type ViewMode = "first-person" | "third-person";
+
+export interface Material {
+  color: string;
+  metalness: number;
+  roughness: number;
+  emissive: string;
+  emissiveIntensity: number;
+}
+
+export interface Physics {
+  mass: number;
+  isStatic: boolean;
+  restitution: number;
+  friction: number;
+  colliderType: ColliderType | "none";
+}
+
+export interface Character {
+  role: string;
+}
+
+export interface Sound {
+  url: string;
+  loop: boolean;
+  volume: number;
+  positional: boolean;
+  distance: number;
+}
+
+export interface Media {
+  url: string;
+  type: string;
+  loop?: boolean;
+  autoplay?: boolean;
+  muted?: boolean;
+}
+
+export interface Damage {
+  amount: number;
+  enabled: boolean;
+}
+
+export interface ShapeData {
+  points: [number, number][];
+  holes: [number, number][][];
+}
+
+export interface MeshData {
+  vertices: number[];
+  indices: number[];
+  normals?: number[];
+  uvs?: number[];
+}
+
+export interface SceneSettings {
+  grid: {
+    visible: boolean;
+    size: number;
+    divisions: number;
+    color: string;
+    secondaryColor: string;
+    fadeDistance: number;
+    fadeStrength: number;
+  };
+  fog: {
+    enabled: boolean;
+    color: string;
+    near: number;
+    far: number;
+  };
+}
+
+export interface EnvironmentSettings {
+  preset: string;
+  customHdri: string | null;
+  intensity: number;
+  blur: number;
+}
+
+export interface SceneObject {
+  id: string;
+  name: string;
+  description?: string;
+  type: ObjectType;
+  position: [number, number, number];
+  rotation: [number, number, number];
+  scale: [number, number, number];
+  material: Material;
+  physics: Physics;
+  visible: boolean;
+  intensity?: number;
+  distance?: number;
+  decay?: number;
+  angle?: number;
+  penumbra?: number;
+  modelUrl?: string;
+  character?: Character;
+  sound?: Sound;
+  media?: Media;
+  damage?: Damage;
+  lightType?: LightType;
+  isPlayer?: boolean;
+  shapeData?: ShapeData;
+  meshData?: MeshData;
+
+  // Geometry props (preserved through spec round-trip)
+  width?: number;
+  height?: number;
+  depth?: number;
+  radius?: number;
+  radiusTop?: number;
+  radiusBottom?: number;
+  radialSegments?: number;
+  tube?: number;
+  tubularSegments?: number;
+  widthSegments?: number;
+  heightSegments?: number;
+  length?: number;
+  p?: number;
+  q?: number;
+}
+
+export interface Scene {
+  id: string;
+  name: string;
+  description?: string;
+  isDefault?: boolean;
+  objects: SceneObject[];
+  sceneSettings: SceneSettings;
+  environmentSettings: EnvironmentSettings;
+}
+
+export interface HistoryState {
+  past: Scene[][];
+  future: Scene[][];
+}
+
+export interface EditorState {
+  scenes: Scene[];
+  activeSceneId: string;
+  selectedObjectId: string | null;
+  transformMode: TransformMode;
+  isPlaying: boolean;
+  isLoading: boolean;
+  viewMode: ViewMode;
+  isPromptOpen: boolean;
+
+  health: number;
+  shield: number;
+  maxHealth: number;
+  maxShield: number;
+  lastDamageTime: number | null;
+  damageCooldown: number;
+
+  history: HistoryState;
+  canUndo: boolean;
+  canRedo: boolean;
+
+  // Computed
+  activeScene: Scene | undefined;
+  activeSceneObjects: SceneObject[];
+
+  // Scene actions
+  createScene: (name?: string) => void;
+  deleteScene: (id: string) => void;
+  duplicateScene: (id: string) => void;
+  setActiveScene: (id: string) => void;
+  updateSceneName: (id: string, name: string) => void;
+  updateSceneDescription: (id: string, description: string) => void;
+
+  // Object actions
+  addObject: (type: ObjectType) => void;
+  createCustomObject: (
+    type: ObjectType,
+    properties: Partial<SceneObject>,
+  ) => void;
+  removeObject: (id: string) => void;
+  replaceSceneObjects: (objects: SceneObject[]) => void;
+  initializePlayer: () => void;
+
+  // Updates
+  selectObject: (id: string | null) => void;
+  updateObjectTransform: (
+    id: string,
+    transform: Partial<Pick<SceneObject, "position" | "rotation" | "scale">>,
+  ) => void;
+  updateObjectMaterial: (id: string, material: Partial<Material>) => void;
+  updateObjectPhysics: (id: string, physics: Partial<Physics>) => void;
+  updateCharacter: (id: string, character: Partial<Character>) => void;
+  updateSound: (id: string, sound: Partial<Sound>) => void;
+  updateMedia: (id: string, media: Partial<Media>) => void;
+  updateDamage: (id: string, damage: Partial<Damage>) => void;
+  updateSceneSettings: (settings: Partial<SceneSettings>) => void;
+  updateEnvironmentSettings: (settings: Partial<EnvironmentSettings>) => void;
+  updateObjectGeneral: (id: string, updates: Partial<SceneObject>) => void;
+  toggleObjectVisibility: (id: string) => void;
+
+  // UI state
+  setTransformMode: (mode: TransformMode) => void;
+  setIsPlaying: (playing: boolean) => void;
+  setIsLoading: (loading: boolean) => void;
+  setViewMode: (mode: ViewMode) => void;
+  setIsPromptOpen: (open: boolean) => void;
+
+  // History
+  saveToHistory: () => void;
+  undo: () => void;
+  redo: () => void;
+
+  // Health
+  setHealth: (health: number) => void;
+  setShield: (shield: number) => void;
+  takeDamage: (amount: number) => void;
+}

+ 25 - 0
examples/game-engine/lib/use-mobile.ts

@@ -0,0 +1,25 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+
+const MOBILE_BREAKPOINT = 768;
+
+function getIsMobile() {
+  return typeof window !== "undefined"
+    ? window.innerWidth < MOBILE_BREAKPOINT
+    : false;
+}
+
+function subscribe(callback: () => void) {
+  const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
+  mql.addEventListener("change", callback);
+  window.addEventListener("resize", callback);
+  return () => {
+    mql.removeEventListener("change", callback);
+    window.removeEventListener("resize", callback);
+  };
+}
+
+export function useIsMobile() {
+  return useSyncExternalStore(subscribe, getIsMobile, () => false);
+}

+ 6 - 0
examples/game-engine/next-env.d.ts

@@ -0,0 +1,6 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+import "./.next/dev/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 12 - 0
examples/game-engine/next.config.ts

@@ -0,0 +1,12 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+  transpilePackages: [
+    "@json-render/core",
+    "@json-render/react",
+    "@json-render/react-three-fiber",
+    "three",
+  ],
+};
+
+export default nextConfig;

+ 50 - 0
examples/game-engine/package.json

@@ -0,0 +1,50 @@
+{
+  "name": "example-game-engine",
+  "version": "0.1.0",
+  "type": "module",
+  "private": true,
+  "scripts": {
+    "predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
+    "dev": "portless game-engine-demo.json-render next dev --turbopack",
+    "build": "next build",
+    "start": "next start",
+    "lint": "eslint --max-warnings 0",
+    "check-types": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@ai-sdk/gateway": "^3.0.66",
+    "@json-render/core": "workspace:*",
+    "@json-render/react": "workspace:*",
+    "@json-render/react-three-fiber": "workspace:*",
+    "@json-render/yaml": "workspace:*",
+    "@react-three/drei": "^10.7.7",
+    "@react-three/fiber": "^9.5.0",
+    "@react-three/postprocessing": "^3.0.4",
+    "@react-three/rapier": "^2.2.0",
+    "@upstash/ratelimit": "^2.0.8",
+    "@upstash/redis": "^1.37.0",
+    "@vercel/blob": "^2.3.1",
+    "ai": "^6.0.116",
+    "lucide-react": "^0.577.0",
+    "next": "16.1.6",
+    "react": "19.2.4",
+    "react-dom": "19.2.4",
+    "three": "^0.183.2",
+    "uuid": "^13.0.0",
+    "yaml": "^2.8.2",
+    "zod": "4.3.6",
+    "zustand": "^5.0.12"
+  },
+  "devDependencies": {
+    "@internal/eslint-config": "workspace:*",
+    "@tailwindcss/postcss": "^4.2.2",
+    "@types/node": "^22.10.0",
+    "@types/react": "19.2.3",
+    "@types/react-dom": "19.2.3",
+    "@types/three": "^0.183.1",
+    "@types/uuid": "^10.0.0",
+    "eslint": "^9.39.1",
+    "tailwindcss": "^4.2.2",
+    "typescript": "^5.7.2"
+  }
+}

+ 5 - 0
examples/game-engine/postcss.config.mjs

@@ -0,0 +1,5 @@
+export default {
+  plugins: {
+    "@tailwindcss/postcss": {},
+  },
+};

+ 13 - 0
examples/game-engine/tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "extends": "../../packages/typescript-config/nextjs.json",
+  "compilerOptions": {
+    "plugins": [{ "name": "next" }],
+    "declaration": false,
+    "declarationMap": false,
+    "paths": {
+      "@/*": ["./*"]
+    }
+  },
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+  "exclude": ["node_modules"]
+}

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 415 - 48
pnpm-lock.yaml


+ 1 - 0
scripts/generate-og-images.mts

@@ -22,6 +22,7 @@ interface Example {
 const EXAMPLES: Example[] = [
   { dir: "chat", title: "Chat Example", framework: "nextjs" },
   { dir: "dashboard", title: "Dashboard Example", framework: "nextjs" },
+  { dir: "game-engine", title: "Game Engine Demo", framework: "nextjs" },
   { dir: "image", title: "Image Example", framework: "nextjs" },
   { dir: "no-ai", title: "No AI Example", framework: "nextjs" },
   { dir: "react-email", title: "React Email Example", framework: "nextjs" },

+ 1 - 1
turbo.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://turborepo.dev/schema.json",
   "ui": "tui",
-  "globalEnv": ["AI_GATEWAY_MODEL", "KV_REST_API_URL", "KV_REST_API_TOKEN", "RATE_LIMIT_PER_MINUTE", "RATE_LIMIT_PER_DAY"],
+  "globalEnv": ["AI_GATEWAY_MODEL", "ELEVENLABS_API_KEY", "KV_REST_API_URL", "KV_REST_API_TOKEN", "RATE_LIMIT_PER_MINUTE", "RATE_LIMIT_PER_DAY"],
   "tasks": {
     "build": {
       "dependsOn": ["^build"],

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác