Offline — no multiplayer backend attached.
;
+ }
+ return (
+
+ {frames.map((frameMs, index) => (
+
LONG_FRAME_MS ? "bg-red-400" : "bg-cyan-400/70"}`}
+ style={{ height: `${Math.min(100, (frameMs / (LONG_FRAME_MS * 2)) * 100)}%` }}
+ title={`${frameMs.toFixed(1)}ms`}
+ />
+ ))}
+
+ );
+}
+
+function PhaseBars({ phases, avgSimMs }: { phases: readonly PhaseStats[]; avgSimMs: number }) {
+ if (phases.length === 0) {
+ return
No phase samples yet.
;
+ }
+ const budget = Math.max(PHASE_BAR_BUDGET_MS, avgSimMs, ...phases.map((phase) => phase.avgMs));
+ return (
+
+ {phases.slice(0, 8).map((phase) => {
+ const hot = phase.avgMs > 4 || phase.maxMs > 8;
+ return (
+
+
+
+ {phase.name}
+
+
+ {ms(phase.avgMs)}
+ avg
+ ·
+ {ms(phase.maxMs)}
+ max
+ ·
+ {phase.pctOfSim.toFixed(0)}%
+
+
+
+
+ );
+ })}
+
+ );
+}
+
+function BudgetSplit({ simMs, outsideMs }: { simMs: number; outsideMs: number }) {
+ const total = Math.max(1e-6, simMs + outsideMs);
+ const simPct = (simMs / total) * 100;
+ const outsideHot = outsideMs > simMs && outsideMs > 4;
+ const simHot = simMs > 8;
+ return (
+
+
+
+ sim {ms(simMs)}
+ outside {ms(outsideMs)}
+
+
+ outside = render / React / GPU / GC / missed vsync — not measured inside the sim driver
+
+
+ );
+}
+
+function LongFrameList({ events }: { events: readonly LongFrameEvent[] }) {
+ if (events.length === 0) {
+ return
No long frames (>{ms(LONG_FRAME_MS)}) captured yet.
;
+ }
+ const newest = [...events].reverse().slice(0, 12);
+ return (
+
+ {newest.map((event, index) => (
+
+
+ {ms(event.frameMs)}
+
+ {event.culprit}
+
+ {new Date(event.at).toLocaleTimeString()}
+
+
{event.reason}
+ {event.phases.length > 0 ? (
+
+ {event.phases
+ .slice(0, 4)
+ .map((phase) => `${phase.name} ${phase.ms.toFixed(1)}`)
+ .join(" · ")}
+ {event.outsideMs >= 2 ? ` · outside ${event.outsideMs.toFixed(1)}` : ""}
+
+ ) : event.outsideMs >= 2 ? (
+
outside {event.outsideMs.toFixed(1)}
+ ) : null}
+ {event.render !== null ? (
+
+ draws {event.render.drawCalls} · tris {event.render.triangles.toLocaleString()}
+
+ ) : null}
+
+ ))}
+
+ );
+}
+
+export function PerfPanel({ ctx }: { ctx: GameContext }) {
+ const rateRef = useRef<{ version: number; at: number; perSecond: number }>({ version: 0, at: 0, perSecond: 0 });
+ const frame = devtools.frame.stats();
+ const render = devtools.render.latest();
+ const longs = devtools.frame.longFrames();
+ const now = performance.now();
+ const rate = rateRef.current;
+ if (rate.at === 0) {
+ rateRef.current = { version: ctx.version(), at: now, perSecond: 0 };
+ } else if (now - rate.at >= 900) {
+ const perSecond = ((ctx.version() - rate.version) / (now - rate.at)) * 1000;
+ rateRef.current = { version: ctx.version(), at: now, perSecond };
+ }
+ const diagnosis = frame !== null ? diagnose(frame, longs) : null;
+ return (
+
+ {frame === null ? (
+
Waiting for frames…
+ ) : (
+ <>
+
+
+
LONG_FRAME_MS} />
+ 8} />
+ 12} />
+ 5} />
+ {diagnosis !== null ? (
+
+ Why slow ·
+ {diagnosis}
+
+ ) : (
+ Frame budget healthy — no dominant hitch pattern.
+ )}
+
+ frame budget
+
+
+
+ >
+ )}
+
+
+ long frames
+ {longs.length > 0 ? (
+
+ ) : null}
+
+
+
+ {render !== null ? (
+
+ render sample
+ 1000} />
+
+
+
+ ) : null}
+
+ probes
+ 90} />
+ {Object.entries(devtools.probes.read()).map(([name, value]) => (
+
+ ))}
+
+
+ Game code: measure("physics", () => …) inside onTick. Agents: snapshot().longFrames + frame.phases
+
+
+ );
+}
diff --git a/packages/shell/src/devtools/TunePanel.tsx b/packages/shell/src/devtools/TunePanel.tsx
new file mode 100644
index 000000000..ab89a8f48
--- /dev/null
+++ b/packages/shell/src/devtools/TunePanel.tsx
@@ -0,0 +1,513 @@
+import { useState } from "react";
+
+import {
+ convertAngle,
+ devtools,
+ parseColor,
+ type DevtoolsControl,
+ type DiscoveredEntry,
+} from "@jgengine/core/devtools/devtools";
+import { getSaveEndpoint } from "@jgengine/core/devtools/saveEndpoint";
+
+import { persistDevtoolsOverrides } from "./devtoolsOverrides";
+
+function ControlInput({ control, onWrite }: { control: DevtoolsControl; onWrite: () => void }) {
+ const value = control.read();
+ const write = (next: unknown) => {
+ if (control.write(next)) onWrite();
+ };
+ if (control.kind === "slider" || control.kind === "angle") {
+ const unit = control.unit ?? "rad";
+ const displayUnit = control.displayUnit ?? (control.kind === "angle" ? "deg" : unit);
+ const displayValue =
+ control.kind === "angle" && typeof value === "number"
+ ? convertAngle(value, unit, displayUnit)
+ : Number(value);
+ const displayMin =
+ control.min !== undefined && control.kind === "angle"
+ ? convertAngle(control.min, unit, displayUnit)
+ : control.min;
+ const displayMax =
+ control.max !== undefined && control.kind === "angle"
+ ? convertAngle(control.max, unit, displayUnit)
+ : control.max;
+ const displayStep =
+ control.step !== undefined && control.kind === "angle" && unit !== displayUnit
+ ? convertAngle(control.step, unit, displayUnit)
+ : control.step;
+ return (
+
+ {
+ const next = Number(event.target.value);
+ write(control.kind === "angle" ? convertAngle(next, displayUnit, unit) : next);
+ }}
+ />
+ {
+ const next = Number(event.target.value);
+ if (!Number.isNaN(next)) {
+ write(control.kind === "angle" ? convertAngle(next, displayUnit, unit) : next);
+ }
+ }}
+ />
+ {control.kind === "angle" ? (
+ {displayUnit}
+ ) : null}
+
+ );
+ }
+ if (control.kind === "toggle") {
+ return (
+
write(event.target.checked)}
+ />
+ );
+ }
+ if (control.kind === "color") {
+ const parsed = parseColor(value);
+ const rgb = parsed?.rgb ?? "#000000";
+ const alpha = parsed?.alpha ?? 1;
+ const showAlpha = control.hasAlpha === true || parsed?.hasAlpha === true;
+ return (
+
+ {
+ if (showAlpha) {
+ const a = Math.round(alpha * 255)
+ .toString(16)
+ .padStart(2, "0");
+ write(`${event.target.value}${a}`);
+ } else {
+ write(event.target.value);
+ }
+ }}
+ />
+ {showAlpha ? (
+ {
+ const nextAlpha = Number(event.target.value);
+ const a = Math.round(nextAlpha * 255)
+ .toString(16)
+ .padStart(2, "0");
+ write(`${rgb}${a}`);
+ }}
+ />
+ ) : null}
+
+ );
+ }
+ if (control.kind === "select" || control.kind === "enum") {
+ const choices =
+ control.choices ??
+ control.options?.map((option) => ({ value: option, label: String(option) })) ??
+ [];
+ return (
+
+ );
+ }
+ if (control.kind === "vec2" || control.kind === "vec3" || control.kind === "vec4") {
+ const axes = Array.isArray(value) ? (value as number[]) : [];
+ const labels = control.axisLabels ?? ["x", "y", "z", "w"];
+ return (
+
+ {axes.map((axis, index) => (
+
+ ))}
+
+ );
+ }
+ if (control.kind === "interval") {
+ const interval =
+ value !== null && typeof value === "object" && !Array.isArray(value)
+ ? (value as { min: number; max: number })
+ : { min: 0, max: 0 };
+ const writeInterval = (min: number, max: number) => write({ min, max });
+ return (
+
+ {
+ const next = Number(event.target.value);
+ if (!Number.isNaN(next)) writeInterval(next, interval.max);
+ }}
+ />
+ …
+ {
+ const next = Number(event.target.value);
+ if (!Number.isNaN(next)) writeInterval(interval.min, next);
+ }}
+ />
+
+ );
+ }
+ return (
+
write(event.target.value)}
+ />
+ );
+}
+
+function formatPreview(value: unknown): string {
+ if (typeof value === "number") return String(Math.round(value * 1000) / 1000);
+ if (Array.isArray(value)) return value.map((entry) => formatPreview(entry)).join(", ");
+ if (value !== null && typeof value === "object") {
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return String(value);
+ }
+ }
+ return String(value);
+}
+
+const TUNABLE_ACRONYMS = new Set([
+ "fov",
+ "fps",
+ "ms",
+ "ui",
+ "ai",
+ "id",
+ "uv",
+ "rgb",
+ "rgba",
+ "hp",
+ "mp",
+ "xp",
+ "npc",
+ "pvp",
+ "pve",
+ "rts",
+ "hud",
+ "lod",
+ "gpu",
+ "cpu",
+]);
+
+function humanizeTunableSegment(segment: string): string {
+ if (/^\d+$/.test(segment)) return `#${segment}`;
+ return segment
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
+ .replace(/[_-]+/g, " ")
+ .split(/\s+/)
+ .filter((part) => part.length > 0)
+ .map((part) => {
+ const lower = part.toLowerCase();
+ if (TUNABLE_ACRONYMS.has(lower)) return lower.toUpperCase();
+ return part.charAt(0).toUpperCase() + part.slice(1);
+ })
+ .join(" ");
+}
+
+function tunableGroupTitle(table: string, key: string): string {
+ const dot = key.lastIndexOf(".");
+ if (dot < 0) return humanizeTunableSegment(table);
+ const parent = key.slice(0, dot);
+ const parts = parent.split(".").map(humanizeTunableSegment);
+ return `${humanizeTunableSegment(table)} · ${parts.join(" · ")}`;
+}
+
+function tunableRowLabel(key: string): string {
+ const leaf = key.includes(".") ? key.slice(key.lastIndexOf(".") + 1) : key;
+ return humanizeTunableSegment(leaf);
+}
+
+function tunableGroupKey(table: string, key: string): string {
+ const dot = key.lastIndexOf(".");
+ return dot < 0 ? table : `${table}/${key.slice(0, dot)}`;
+}
+
+function deltaSnippet(discovered: readonly DiscoveredEntry[]): string | null {
+ const overrides = devtools.overrides.export();
+ if (Object.keys(overrides.values).length === 0) return null;
+ const discoveredById = new Map(discovered.map((entry) => [entry.id, entry]));
+ const tables = new Map
>();
+ const flat: Record = {};
+ for (const [name, value] of Object.entries(overrides.values)) {
+ const entry = discoveredById.get(name);
+ if (entry !== undefined) {
+ const values = tables.get(entry.table) ?? {};
+ values[entry.key] = value;
+ tables.set(entry.table, values);
+ } else {
+ flat[name] = value;
+ }
+ }
+ const parts: string[] = [];
+ for (const [table, values] of tables) parts.push(`${table}: ${JSON.stringify(values, null, 2)}`);
+ if (Object.keys(flat).length > 0) parts.push(`controls: ${JSON.stringify(flat, null, 2)}`);
+ return parts.join("\n\n");
+}
+
+function tunableDeltas(
+ discovered: readonly DiscoveredEntry[],
+): { table: string; key: string; value: unknown }[] {
+ const overrides = devtools.overrides.export();
+ const discoveredById = new Map(discovered.map((entry) => [entry.id, entry]));
+ const deltas: { table: string; key: string; value: unknown }[] = [];
+ for (const [name, value] of Object.entries(overrides.values)) {
+ const entry = discoveredById.get(name);
+ if (entry !== undefined) deltas.push({ table: entry.table, key: entry.key, value });
+ }
+ return deltas;
+}
+
+type SourceSaveState = "idle" | "saving" | "saved" | "partial" | "error";
+
+function SaveToSourceButton({ discovered }: { discovered: readonly DiscoveredEntry[] }) {
+ const [state, setState] = useState("idle");
+ const [detail, setDetail] = useState(null);
+ const endpoint = getSaveEndpoint();
+ if (endpoint === null) return null;
+ const deltas = tunableDeltas(discovered);
+ const save = () => {
+ if (deltas.length === 0 || state === "saving") return;
+ setState("saving");
+ void fetch(endpoint.url, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ kind: "tunables", gameId: endpoint.gameId, deltas }),
+ })
+ .then(async (response) => {
+ const result = (await response.json()) as {
+ ok: boolean;
+ applied?: number;
+ skipped?: readonly { table: string; key: string; reason: string }[];
+ error?: string;
+ };
+ if (!result.ok) {
+ setState("error");
+ setDetail(result.error ?? "save failed");
+ return;
+ }
+ const skipped = result.skipped ?? [];
+ if (skipped.length > 0) {
+ setState("partial");
+ setDetail(skipped.map((entry) => `${entry.table}/${entry.key}: ${entry.reason}`).join("\n"));
+ } else {
+ setState("saved");
+ setDetail(null);
+ }
+ setTimeout(() => setState("idle"), 2500);
+ })
+ .catch((error: unknown) => {
+ setState("error");
+ setDetail(error instanceof Error ? error.message : String(error));
+ });
+ };
+ return (
+
+ );
+}
+
+export function TunePanel({ gameName }: { gameName: string }) {
+ const [deltasCopied, setDeltasCopied] = useState(false);
+ const [query, setQuery] = useState("");
+ const controls = devtools.controls.list();
+ const allDiscovered = devtools.discover.list();
+ const persist = () => persistDevtoolsOverrides(gameName);
+ const discoveredIds = new Set(allDiscovered.map((entry) => entry.id));
+ const needle = query.trim().toLowerCase();
+ const matches = (id: string) => needle === "" || id.toLowerCase().includes(needle);
+ const discovered = allDiscovered.filter((entry) => entry.enabled || matches(entry.id));
+ const explicit = controls.filter((control) => !discoveredIds.has(control.name) && matches(control.name));
+ const controlByName = new Map(controls.map((control) => [control.name, control]));
+ const snippet = deltaSnippet(allDiscovered);
+ const copyDeltas = () => {
+ if (snippet === null) return;
+ const clipboard = navigator.clipboard;
+ if (clipboard !== undefined) {
+ void clipboard.writeText(snippet).catch(() => console.log(snippet));
+ } else {
+ console.log(snippet);
+ }
+ setDeltasCopied(true);
+ setTimeout(() => setDeltasCopied(false), 1500);
+ };
+ if (controls.length === 0 && allDiscovered.length === 0) {
+ return (
+
+
Nothing discovered.
+
+ {"export const TUNING = { gravity: -22, skyColor: \"#87ceeb\" };"}
+
+ {"Nested numbers/booleans/colors auto-discover. Schema kinds:"}
+
+ {"vec2/3/4 · interval · angle · enum · color+alpha via tunable() or scan meta."}
+
+
+ );
+ }
+ const groups = new Map();
+ for (const entry of discovered) {
+ const groupKey = tunableGroupKey(entry.table, entry.key);
+ const existing = groups.get(groupKey);
+ if (existing !== undefined) {
+ existing.entries.push(entry);
+ } else {
+ groups.set(groupKey, { title: tunableGroupTitle(entry.table, entry.key), entries: [entry] });
+ }
+ }
+ return (
+
+
+
+
+
+ setQuery(event.target.value)}
+ placeholder="filter…"
+ className="min-w-0 flex-1 rounded-md border border-white/10 bg-black/30 px-2 py-0.5 text-neutral-200 transition-colors placeholder:text-neutral-600 focus:border-cyan-400/60 focus:outline-none"
+ />
+
+ {explicit.length === 0 && discovered.length === 0 ? (
+
No tunables match “{query}”.
+ ) : null}
+ {explicit.length > 0 ? (
+
+
registered
+ {explicit.map((control) => (
+
+
+ {control.label.includes(".") ? tunableRowLabel(control.label) : control.label}
+
+
+
+ ))}
+
+ ) : null}
+ {[...groups.entries()].map(([groupKey, group]) => (
+
+
{group.title}
+ {group.entries.map((entry) => {
+ const control = entry.enabled ? controlByName.get(entry.id) : undefined;
+ return (
+
+
+ {control !== undefined ? (
+
+ ) : (
+ {formatPreview(entry.read())}
+ )}
+
+ );
+ })}
+
+ ))}
+
+ );
+}
diff --git a/packages/shell/src/devtools/devtoolsOverrides.ts b/packages/shell/src/devtools/devtoolsOverrides.ts
new file mode 100644
index 000000000..66f7c4610
--- /dev/null
+++ b/packages/shell/src/devtools/devtoolsOverrides.ts
@@ -0,0 +1,47 @@
+import { devtools, parseOverridesPayload, type DevtoolsOverrides } from "@jgengine/core/devtools/devtools";
+
+function overridesStorageKey(gameName: string): string {
+ return `jg-devtools:${gameName}`;
+}
+
+export function readStoredOverrides(gameName: string): DevtoolsOverrides | null {
+ try {
+ const raw = localStorage.getItem(overridesStorageKey(gameName));
+ if (raw === null) return null;
+ const parsed = parseOverridesPayload(JSON.parse(raw) as unknown);
+ if (parsed.overrides === null) {
+ for (const message of parsed.diagnostics) console.warn(`[jgengine:devtools] ${message}`);
+ return null;
+ }
+ for (const message of parsed.diagnostics) console.info(`[jgengine:devtools] ${message}`);
+ return parsed.overrides;
+ } catch {
+ return null;
+ }
+}
+
+/** @internal */
+export function persistDevtoolsOverrides(gameName: string): DevtoolsOverrides {
+ const overrides = devtools.overrides.export();
+ try {
+ localStorage.setItem(overridesStorageKey(gameName), JSON.stringify(overrides));
+ } catch {
+ return overrides;
+ }
+ return overrides;
+}
+
+/** @internal */
+export function applyStoredDevtoolsOverrides(gameName: string): void {
+ const stored = readStoredOverrides(gameName);
+ if (stored === null) return;
+ const result = devtools.overrides.apply(stored);
+ if (result.applied === 0 && result.skipped.length === 0) return;
+ console.info(
+ `[jgengine:devtools] applied ${result.applied} stored override(s) for ${gameName}` +
+ (result.skipped.length > 0 ? ` · skipped ${result.skipped.length}` : ""),
+ );
+ for (const entry of result.skipped) {
+ console.warn(`[jgengine:devtools] skipped override ${entry.id}: ${entry.reason}`);
+ }
+}
diff --git a/packages/shell/src/devtools/panelAtoms.tsx b/packages/shell/src/devtools/panelAtoms.tsx
new file mode 100644
index 000000000..9350c54e8
--- /dev/null
+++ b/packages/shell/src/devtools/panelAtoms.tsx
@@ -0,0 +1,16 @@
+export function ms(value: number): string {
+ return `${value.toFixed(1)}ms`;
+}
+
+export function StatRow({ name, value, alert }: { name: string; value: string; alert?: boolean }) {
+ return (
+
+ {name}
+ {value}
+
+ );
+}
+
+export function SectionLabel({ children }: { children: string }) {
+ return {children}
;
+}
diff --git a/packages/shell/src/devtools/perfDiagnose.ts b/packages/shell/src/devtools/perfDiagnose.ts
new file mode 100644
index 000000000..7089d0ce5
--- /dev/null
+++ b/packages/shell/src/devtools/perfDiagnose.ts
@@ -0,0 +1,44 @@
+import { devtools, LONG_FRAME_MS, type LongFrameEvent } from "@jgengine/core/devtools/devtools";
+
+import { ms } from "./panelAtoms";
+
+const CULPRIT_HINTS: Record = {
+ "outside-sim": "Cost is outside the sim driver — three.js render, React commit, GPU, GC, or tab throttle.",
+ sim: "Sim is slow but no named phase stands out — wrap hot onTick work with measure(\"name\", fn).",
+ onTick: "Game loop.onTick is the hotspot — profile inside it with measure(\"physics\"|\"ai\"|…, fn).",
+ pose: "Shell movement / collision / voxel step is expensive.",
+ actions: "Input → command dispatch / hotbar / interact is expensive.",
+ presence: "Multiplayer pose sync is expensive.",
+ pickup: "Auto-pickup nearest scan is expensive.",
+ "time+input": "Clock advance or input publish is unexpectedly heavy.",
+};
+
+export function diagnose(frame: NonNullable>, longs: readonly LongFrameEvent[]): string | null {
+ if (frame.fps >= 55 && frame.longFrames === 0 && longs.length === 0) return null;
+ const recent = longs.slice(-5);
+ const culpritCounts = new Map();
+ for (const event of recent) {
+ culpritCounts.set(event.culprit, (culpritCounts.get(event.culprit) ?? 0) + 1);
+ }
+ let topCulprit: string | null = null;
+ let topCount = 0;
+ for (const [name, count] of culpritCounts) {
+ if (count > topCount) {
+ topCulprit = name;
+ topCount = count;
+ }
+ }
+ if (topCulprit === null && frame.phases[0] !== undefined && frame.avgSimMs > 8) {
+ topCulprit = frame.phases[0].name;
+ }
+ if (topCulprit === null && frame.avgOutsideMs > frame.avgSimMs && frame.avgOutsideMs > 4) {
+ topCulprit = "outside-sim";
+ }
+ if (topCulprit === null) {
+ if (frame.p95FrameMs > LONG_FRAME_MS) return `p95 ${ms(frame.p95FrameMs)} — hitching without a clear phase; check long-frame log as it fills.`;
+ return null;
+ }
+ const hint = CULPRIT_HINTS[topCulprit] ?? `Hotspot “${topCulprit}” — dig into that phase with measure() or reduce its work.`;
+ const countText = topCount > 0 ? ` (${topCount}/${recent.length || topCount} recent long frames)` : "";
+ return `${topCulprit}${countText}: ${hint}`;
+}
diff --git a/packages/shell/src/diagnostics/RuntimeDiagnostics.tsx b/packages/shell/src/diagnostics/RuntimeDiagnostics.tsx
new file mode 100644
index 000000000..a998a86e6
--- /dev/null
+++ b/packages/shell/src/diagnostics/RuntimeDiagnostics.tsx
@@ -0,0 +1,123 @@
+import { Component, useState, type ErrorInfo, type ReactNode } from "react";
+
+import { VERSION } from "@jgengine/core/meta/changelog";
+
+export interface RuntimeDiagnostic {
+ id: number;
+ phase: string;
+ message: string;
+ stack?: string;
+ componentStack?: string;
+ capturedAt: string;
+}
+
+function errorToDiagnostic(error: unknown, phase: string, componentStack?: string): Omit {
+ const capturedAt = new Date().toISOString();
+ if (error instanceof Error) {
+ return { phase, message: error.message, stack: error.stack, componentStack, capturedAt };
+ }
+ return { phase, message: typeof error === "string" ? error : JSON.stringify(error), componentStack, capturedAt };
+}
+
+export function logRuntimeError(error: unknown, phase: string, componentStack?: string): Omit {
+ const diagnostic = errorToDiagnostic(error, phase, componentStack);
+ console.error(`[jgengine:${phase}] ${diagnostic.message}`, error);
+ return diagnostic;
+}
+
+export class GameUiErrorBoundary extends Component<
+ { children: ReactNode; onRuntimeError: (error: unknown, phase: string, componentStack?: string) => void },
+ { failed: boolean }
+> {
+ state = { failed: false };
+
+ static getDerivedStateFromError() {
+ return { failed: true };
+ }
+
+ componentDidCatch(error: unknown, info: ErrorInfo) {
+ this.props.onRuntimeError(error, "ui-render", info.componentStack ?? undefined);
+ }
+
+ render() {
+ if (this.state.failed) return null;
+ return this.props.children;
+ }
+}
+
+function expandedReactError(message: string): string | null {
+ return /Minified React error #185\b/.test(message)
+ ? "Maximum update depth exceeded. A component is repeatedly updating state during render or after every update."
+ : null;
+}
+
+function diagnosticReport(diagnostic: RuntimeDiagnostic, gameName: string): string {
+ const explanation = expandedReactError(diagnostic.message);
+ return [
+ "JGengine runtime error",
+ `Game: ${gameName}`,
+ `Engine: ${VERSION}`,
+ `Phase: ${diagnostic.phase}`,
+ `Time: ${diagnostic.capturedAt}`,
+ `Page: ${window.location.origin}${window.location.pathname}`,
+ `Browser: ${navigator.userAgent}`,
+ explanation === null ? null : `Explanation: ${explanation}`,
+ "",
+ `Message: ${diagnostic.message}`,
+ diagnostic.stack === undefined ? null : `JavaScript stack:\n${diagnostic.stack}`,
+ diagnostic.componentStack === undefined ? null : `React component stack:\n${diagnostic.componentStack}`,
+ ]
+ .filter((line): line is string => line !== null)
+ .join("\n");
+}
+
+export function DiagnosticOverlay({ diagnostics, gameName }: { diagnostics: RuntimeDiagnostic[]; gameName: string }) {
+ const [copied, setCopied] = useState(false);
+ if (diagnostics.length === 0) return null;
+ const latest = diagnostics[diagnostics.length - 1]!;
+ const explanation = expandedReactError(latest.message);
+ const report = diagnosticReport(latest, gameName);
+ const issueBody = report.length > 8000 ? `${report.slice(0, 8000)}\n\n[Report truncated; use Copy error for the full report.]` : report;
+ const issueUrl = `https://github.com/Noisemaker111/jgengine/issues/new?title=${encodeURIComponent(`[BUG] ${latest.phase}: ${latest.message.slice(0, 100)}`)}&body=${encodeURIComponent(issueBody)}`;
+ return (
+
+
JG engine error
+
+ [{latest.phase}] {latest.message}
+
+ {explanation !== null ?
{explanation}
: null}
+ {latest.stack !== undefined ? (
+
+ {latest.stack}
+
+ ) : null}
+ {latest.componentStack !== undefined ? (
+
+ {latest.componentStack}
+
+ ) : null}
+
+
+
+ File issue
+
+
+
+ );
+}
diff --git a/packages/shell/src/drivers/FrameDriver.tsx b/packages/shell/src/drivers/FrameDriver.tsx
new file mode 100644
index 000000000..0499790af
--- /dev/null
+++ b/packages/shell/src/drivers/FrameDriver.tsx
@@ -0,0 +1,290 @@
+import { useFrame } from "@react-three/fiber";
+import { useMemo, useRef } from "react";
+
+import { RESERVED_INPUT_ACTIONS, dispatchBoundAction, heldActionsFor, shouldFireBoundAction } from "../boundActionDispatch";
+import { executeHotbarSlot, findHotbarSlotActions, hotbarIdFor } from "../hotbarActions";
+import { pointerAimFor } from "../shellPointer";
+import { shellDrivesPlayerPose } from "../shellMovement";
+import type { Aim } from "@jgengine/core/scene/spatial";
+import { steerYaw } from "@jgengine/core/movement/steering";
+import { stepPlayerMovement, resolvePlayerMovementTuning } from "@jgengine/core/movement/playerMovement";
+import type { GameContext } from "@jgengine/core/runtime/gameContext";
+import { isServerAuthoritative } from "@jgengine/core/runtime/adapter";
+import { resolveCommandSink, type CommandSink } from "../commandSink";
+import { inputFramesEqual, resolveInputSink, type InputSink } from "../inputSink";
+import type { InputFrame } from "@jgengine/core/runtime/hostedGameRunner";
+import { advanceBehaviors } from "@jgengine/core/scene/behaviorRuntime";
+import { resolveActivePrompt } from "@jgengine/core/interaction/proximityPrompt";
+import type { PointerAxisState } from "@jgengine/core/input/pointerAxis";
+import { DEFAULT_PICKUP_RADIUS } from "@jgengine/core/game/worldItem";
+import { playControlsActive } from "@jgengine/core/game/controlGate";
+import { devtools } from "@jgengine/core/devtools/devtools";
+import type { ActionStateTracker } from "@jgengine/core/input/actionBindings";
+
+import { collisionDebug } from "../devtools/collisionDebug";
+import { GAME_SIM_FRAME_PRIORITY } from "../camera";
+import { NO_ACTIONS } from "../shellConstants";
+import type { PointerService } from "../pointer/pointerService";
+import type { ShellMultiplayer } from "../multiplayer";
+import type { PlayableGame } from "../registry";
+
+const TURN_SPEED = 2.4;
+
+export const POSTER_SETTLE_SECONDS = 1.6;
+
+export function FrameDriver({
+ ctx,
+ playable,
+ tracker,
+ yawRef,
+ pitchRef,
+ primaryClickRef,
+ pointerAxisRef,
+ gateRef,
+ onRuntimeError,
+ multiplayer,
+ serverIdRef,
+ pointerService,
+ pointerAim,
+ pingCommand,
+ poster,
+ onPosterSettled,
+}: {
+ ctx: GameContext;
+ playable: PlayableGame;
+ tracker: ActionStateTracker;
+ yawRef: { current: number };
+ pitchRef: { current: number };
+ primaryClickRef: { current: boolean };
+ pointerAxisRef: { current: PointerAxisState | null };
+ gateRef: { current: boolean };
+ onRuntimeError: (error: unknown, phase: string) => void;
+ multiplayer: ShellMultiplayer | null;
+ serverIdRef: { current: string | null };
+ pointerService: PointerService;
+ pointerAim: boolean;
+ pingCommand: string | undefined;
+ poster: boolean;
+ onPosterSettled: () => void;
+}) {
+ const posterElapsedRef = useRef(0);
+ const posterDoneRef = useRef(false);
+ const hasReportedTickError = useRef(false);
+ const repeatFiredAtRef = useRef