@jgengine/editor 0.19.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +364 -0
  2. package/dist/ClipPreviewLayer.d.ts +2 -3
  3. package/dist/ClipPreviewLayer.js +10 -28
  4. package/dist/CreatorApp.d.ts +12 -0
  5. package/dist/CreatorApp.js +107 -0
  6. package/dist/DebugDraw.js +3 -1
  7. package/dist/EditorApp.d.ts +14 -2
  8. package/dist/EditorApp.js +29 -14
  9. package/dist/EditorChrome.d.ts +9 -1
  10. package/dist/EditorChrome.js +54 -35
  11. package/dist/InspectorPanel.d.ts +3 -1
  12. package/dist/InspectorPanel.js +12 -8
  13. package/dist/MaterialAssetEditor.d.ts +13 -0
  14. package/dist/MaterialAssetEditor.js +109 -0
  15. package/dist/MaterialPreview.d.ts +16 -0
  16. package/dist/MaterialPreview.js +41 -0
  17. package/dist/MaterialsWorkspacePanel.d.ts +14 -2
  18. package/dist/MaterialsWorkspacePanel.js +78 -4
  19. package/dist/RuntimePlayBridge.js +1 -6
  20. package/dist/SelectionGizmo.js +23 -14
  21. package/dist/SimulationInspector.d.ts +9 -0
  22. package/dist/SimulationInspector.js +47 -0
  23. package/dist/TerrainSculpt.js +12 -59
  24. package/dist/agent/toolBridge.js +2 -0
  25. package/dist/authoringPlacement.d.ts +21 -0
  26. package/dist/authoringPlacement.js +34 -0
  27. package/dist/handlers/context.d.ts +1 -0
  28. package/dist/handlers/document.d.ts +1 -1
  29. package/dist/handlers/document.js +29 -1
  30. package/dist/handlers/hierarchy.d.ts +1 -1
  31. package/dist/handlers/hierarchy.js +4 -0
  32. package/dist/handlers/runtime.js +20 -17
  33. package/dist/handlers/terrain.js +52 -12
  34. package/dist/index.d.ts +2 -1
  35. package/dist/index.js +1 -0
  36. package/dist/materialControls.d.ts +32 -0
  37. package/dist/materialControls.js +79 -0
  38. package/dist/mcp/fieldSpec.d.ts +1 -1
  39. package/dist/mcp/fieldSpec.js +11 -2
  40. package/dist/mcp/importOptionalGameModule.d.ts +2 -0
  41. package/dist/mcp/importOptionalGameModule.js +13 -0
  42. package/dist/mcp/loadGameCatalogs.js +5 -12
  43. package/dist/mcp/rpcRequest.d.ts +3 -3
  44. package/dist/mcp/rpcRequest.js +10 -4
  45. package/dist/mcp/tools.js +10 -1
  46. package/dist/modelAnimationAuthoring.d.ts +27 -7
  47. package/dist/modelAnimationAuthoring.js +69 -72
  48. package/dist/session.d.ts +62 -0
  49. package/dist/session.js +56 -7
  50. package/dist/shell/AnimationGraphPanel.js +18 -10
  51. package/dist/shell/PlayModeBar.d.ts +2 -1
  52. package/dist/shell/PlayModeBar.js +2 -2
  53. package/dist/shell/SceneToolbar.d.ts +2 -1
  54. package/dist/shell/SceneToolbar.js +5 -4
  55. package/dist/shell/TopAppBar.d.ts +2 -1
  56. package/dist/shell/TopAppBar.js +2 -2
  57. package/dist/shell/animGraphPreview.d.ts +2 -0
  58. package/dist/shell/animGraphPreview.js +5 -2
  59. package/dist/shell/clipPreview.d.ts +1 -0
  60. package/dist/uiStore.d.ts +1 -10
  61. package/dist/uiStore.js +1 -13
  62. package/dist/useDocumentSave.d.ts +20 -0
  63. package/dist/useDocumentSave.js +33 -0
  64. package/package.json +6 -5
  65. package/skills/ce-handoff/SKILL.md +1 -1
  66. package/skills/jgengine/SKILL.md +23 -2
  67. package/skills/jgengine/api.md +28 -17
  68. package/skills/jgengine/recipes/minimal-game.md +7 -2
  69. package/skills/jgengine-editor/SKILL.md +9 -1
  70. package/skills/jgengine-editor/api.md +111 -16
  71. package/skills/jgengine-editor/capabilities.md +40 -0
  72. package/skills/jgengine-editor/reference.md +149 -1
  73. package/skills/jgengine-editor/references/live-sync.md +15 -0
@@ -8,10 +8,11 @@ export type TopBarSaveState = "idle" | "saving" | "saved" | "error";
8
8
  * edit mode; Play mode mounts {@link PlayModeBar}, which wires the same controls to the
9
9
  * runtime pause/step RPCs so mode switches keep shell chrome.
10
10
  */
11
- export declare function TopAppBar({ gameId, onExitToGame, dirty, saveState, lastSavedAt, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }: {
11
+ export declare function TopAppBar({ gameId, onExitToGame, exitLabel, dirty, saveState, lastSavedAt, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }: {
12
12
  gameId: string;
13
13
  /** When set (editor summoned over a game), renders an "Exit to game" control that closes the editor. */
14
14
  onExitToGame?: () => void;
15
+ exitLabel?: string;
15
16
  dirty: boolean;
16
17
  saveState: TopBarSaveState;
17
18
  /** Epoch ms of the last successful save this session; omit/null when never saved. */
@@ -34,7 +34,7 @@ function SaveStatus({ dirty, saveState, lastSavedAt, }) {
34
34
  * edit mode; Play mode mounts {@link PlayModeBar}, which wires the same controls to the
35
35
  * runtime pause/step RPCs so mode switches keep shell chrome.
36
36
  */
37
- export function TopAppBar({ gameId, onExitToGame, dirty, saveState, lastSavedAt = null, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }) {
37
+ export function TopAppBar({ gameId, onExitToGame, exitLabel = "Exit to game", dirty, saveState, lastSavedAt = null, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }) {
38
38
  const [menuOpen, setMenuOpen] = useState(false);
39
39
  const menuRef = useRef(null);
40
40
  useEffect(() => {
@@ -56,7 +56,7 @@ export function TopAppBar({ gameId, onExitToGame, dirty, saveState, lastSavedAt
56
56
  };
57
57
  }, [menuOpen]);
58
58
  const menuItem = `flex w-full items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-[12px] text-neutral-300 transition-colors hover:bg-cyan-500/15 hover:text-cyan-100 ${FOCUS_RING}`;
59
- return (_jsxs("header", { className: `pointer-events-auto relative z-[60] flex h-12 shrink-0 items-center gap-2 border-b ${BORDER} bg-[#111318] px-2.5`, children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2.5", children: [onExitToGame !== undefined ? (_jsxs("button", { type: "button", onClick: onExitToGame, title: "Close the editor and return to the game (F2+Q)", className: `flex h-8 shrink-0 items-center gap-1.5 rounded-[6px] border border-white/[0.08] bg-black/30 pl-2 pr-2.5 text-[12px] text-neutral-300 transition-colors hover:border-white/[0.16] hover:bg-white/[0.06] hover:text-neutral-100 ${FOCUS_RING}`, children: [_jsx(Icon, { name: "chevronRight", size: 14, className: "rotate-180" }), "Exit to game"] })) : null, _jsx("div", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px] bg-gradient-to-br from-cyan-500/30 to-indigo-500/25 text-[11px] font-bold tracking-wide text-cyan-200 ring-1 ring-inset ring-cyan-400/30", "aria-hidden": "true", children: "JG" }), _jsxs("div", { className: "min-w-0 leading-tight", children: [_jsx("div", { className: "truncate text-[13px] font-semibold text-neutral-100", children: gameId }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Scene Editor" })] }), _jsx("div", { className: "ml-1 hidden md:block", children: _jsx(SaveStatus, { dirty: dirty, saveState: saveState, lastSavedAt: lastSavedAt }) })] }), _jsx("div", { className: "flex flex-1 justify-center px-2", children: _jsxs("button", { type: "button", onClick: onOpenPalette, "aria-label": "Open command palette", className: `flex h-8 w-full max-w-[420px] items-center gap-2 rounded-[6px] border border-white/[0.08] bg-black/30 px-3 text-[12px] text-neutral-500 transition-colors hover:border-white/[0.14] hover:text-neutral-400 ${FOCUS_RING}`, children: [_jsx(Icon, { name: "search", size: 13 }), _jsx("span", { className: "min-w-0 flex-1 truncate text-left", children: "Search or run a command\u2026" }), _jsxs("span", { className: "hidden items-center gap-0.5 sm:flex", children: [_jsx(Kbd, { children: "Ctrl" }), _jsx(Kbd, { children: "K" })] })] }) }), _jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [_jsx(IconButton, { icon: "undo", label: "Undo (Ctrl+Z)", onClick: onUndo, disabled: !canUndo, tone: "ghost" }), _jsx(IconButton, { icon: "redo", label: "Redo (Ctrl+Y)", onClick: onRedo, disabled: !canRedo, tone: "ghost" }), _jsx(ToolbarDivider, {}), _jsxs("button", { type: "button", onClick: onPlay, className: `flex h-8 items-center gap-1.5 rounded-[6px] bg-emerald-600 px-3.5 text-[12px] font-semibold text-white shadow-sm shadow-emerald-950/40 transition-colors hover:bg-emerald-500 ${FOCUS_RING}`, title: "Enter Play mode (F2+E toggles back)", children: [_jsx(Icon, { name: "play", size: 12 }), "Play"] }), _jsx(IconButton, { icon: "pause", label: "Pause \u2014 available in Play mode", disabled: true }), _jsx(IconButton, { icon: "step", label: "Step one frame \u2014 available in Play mode", disabled: true }), _jsx(ToolbarDivider, {}), _jsx(IconButton, { icon: "walk", label: "Walk the world", onClick: onWalk, tone: "ghost" }), _jsx(IconButton, { icon: "panel", label: "Lay out HUD panels", onClick: onHud, tone: "ghost" }), _jsx(ToolbarDivider, {}), saveAvailable ? (_jsxs("button", { type: "button", onClick: onSave, disabled: saveState === "saving", title: saveError ?? "Write the scene to the game's editor.scene.json (Ctrl+S)", className: `flex h-8 items-center gap-1.5 rounded-[6px] px-3 text-[12px] font-medium transition-colors ${FOCUS_RING} ${saveState === "error"
59
+ return (_jsxs("header", { className: `pointer-events-auto relative z-[60] flex h-12 shrink-0 items-center gap-2 border-b ${BORDER} bg-[#111318] px-2.5`, children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2.5", children: [onExitToGame !== undefined ? (_jsxs("button", { type: "button", onClick: onExitToGame, title: `${exitLabel} (F2+Q)`, className: `flex h-8 shrink-0 items-center gap-1.5 rounded-[6px] border border-white/[0.08] bg-black/30 pl-2 pr-2.5 text-[12px] text-neutral-300 transition-colors hover:border-white/[0.16] hover:bg-white/[0.06] hover:text-neutral-100 ${FOCUS_RING}`, children: [_jsx(Icon, { name: "chevronRight", size: 14, className: "rotate-180" }), exitLabel] })) : null, _jsx("div", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px] bg-gradient-to-br from-cyan-500/30 to-indigo-500/25 text-[11px] font-bold tracking-wide text-cyan-200 ring-1 ring-inset ring-cyan-400/30", "aria-hidden": "true", children: "JG" }), _jsxs("div", { className: "min-w-0 leading-tight", children: [_jsx("div", { className: "truncate text-[13px] font-semibold text-neutral-100", children: gameId }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Scene Editor" })] }), _jsx("div", { className: "ml-1 hidden md:block", children: _jsx(SaveStatus, { dirty: dirty, saveState: saveState, lastSavedAt: lastSavedAt }) })] }), _jsx("div", { className: "flex flex-1 justify-center px-2", children: _jsxs("button", { type: "button", onClick: onOpenPalette, "aria-label": "Open command palette", className: `flex h-8 w-full max-w-[420px] items-center gap-2 rounded-[6px] border border-white/[0.08] bg-black/30 px-3 text-[12px] text-neutral-500 transition-colors hover:border-white/[0.14] hover:text-neutral-400 ${FOCUS_RING}`, children: [_jsx(Icon, { name: "search", size: 13 }), _jsx("span", { className: "min-w-0 flex-1 truncate text-left", children: "Search or run a command\u2026" }), _jsxs("span", { className: "hidden items-center gap-0.5 sm:flex", children: [_jsx(Kbd, { children: "Ctrl" }), _jsx(Kbd, { children: "K" })] })] }) }), _jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [_jsx(IconButton, { icon: "undo", label: "Undo (Ctrl+Z)", onClick: onUndo, disabled: !canUndo, tone: "ghost" }), _jsx(IconButton, { icon: "redo", label: "Redo (Ctrl+Y)", onClick: onRedo, disabled: !canRedo, tone: "ghost" }), _jsx(ToolbarDivider, {}), _jsxs("button", { type: "button", onClick: onPlay, className: `flex h-8 items-center gap-1.5 rounded-[6px] bg-emerald-600 px-3.5 text-[12px] font-semibold text-white shadow-sm shadow-emerald-950/40 transition-colors hover:bg-emerald-500 ${FOCUS_RING}`, title: "Enter Play mode (F2+E toggles back)", children: [_jsx(Icon, { name: "play", size: 12 }), "Play"] }), _jsx(IconButton, { icon: "pause", label: "Pause \u2014 available in Play mode", disabled: true }), _jsx(IconButton, { icon: "step", label: "Step one frame \u2014 available in Play mode", disabled: true }), _jsx(ToolbarDivider, {}), _jsx(IconButton, { icon: "walk", label: "Walk the world", onClick: onWalk, tone: "ghost" }), _jsx(IconButton, { icon: "panel", label: "Lay out HUD panels", onClick: onHud, tone: "ghost" }), _jsx(ToolbarDivider, {}), saveAvailable ? (_jsxs("button", { type: "button", onClick: onSave, disabled: saveState === "saving", title: saveError ?? "Write the scene to the game's editor.scene.json (Ctrl+S)", className: `flex h-8 items-center gap-1.5 rounded-[6px] px-3 text-[12px] font-medium transition-colors ${FOCUS_RING} ${saveState === "error"
60
60
  ? "bg-rose-600/90 text-white hover:bg-rose-500"
61
61
  : dirty
62
62
  ? "bg-cyan-600 text-white shadow-sm shadow-cyan-950/40 hover:bg-cyan-500"
@@ -34,6 +34,8 @@ export interface GraphPreviewFrame {
34
34
  name: string;
35
35
  clip: string;
36
36
  }[];
37
+ /** Active root-motion playback stays in place in the editor, matching the collision-authoritative shell. */
38
+ rootMotion?: true;
37
39
  }
38
40
  /** Longest preview timeline the scrubber offers, in seconds. */
39
41
  export declare const MAX_PREVIEW_SECONDS = 20;
@@ -31,15 +31,18 @@ export function simulateGraphPreview(input) {
31
31
  const states = {};
32
32
  for (const layer of input.graph.layers)
33
33
  states[layer.id] = runtime.stateOf(layer.id) ?? layer.entry;
34
- return { time, states, clips: out.clips, events };
34
+ return { time, states, clips: out.clips, events, ...(out.rootMotion === true ? { rootMotion: true } : {}) };
35
35
  }
36
36
  /** Every clip name a graph plays. */
37
37
  export function graphClipNames(graph) {
38
38
  const names = new Set();
39
39
  for (const layer of graph.layers) {
40
40
  for (const state of Object.values(layer.states)) {
41
- if (state.kind === "clip")
41
+ if (state.kind === "clip") {
42
42
  names.add(state.clip);
43
+ for (const clip of state.variants ?? [])
44
+ names.add(clip);
45
+ }
43
46
  else
44
47
  for (const point of state.points)
45
48
  names.add(point.clip);
@@ -46,6 +46,7 @@ export interface ClipPreviewSession {
46
46
  readonly graphPose?: {
47
47
  readonly graph: AnimGraph;
48
48
  readonly clips: readonly AnimClipOutput[];
49
+ readonly rootMotion?: true;
49
50
  };
50
51
  }
51
52
  /** Playback state of the clip preview driver. `time` is seconds into the clip. */
package/dist/uiStore.d.ts CHANGED
@@ -7,16 +7,7 @@ export type EditorTool = "select" | "terrain";
7
7
  export type TerrainBrushKind = "raise" | "lower" | "smooth" | "flatten" | "noise" | "ramp";
8
8
  /** The terrain tool's active sub-mode: reshape the heightfield, or paint material layers onto it. */
9
9
  export type TerrainMode = "sculpt" | "paint";
10
- /** A paintable terrain material layer — a surface id plus the color it renders as. */
11
- export interface TerrainMaterial {
12
- id: string;
13
- label: string;
14
- color: string;
15
- }
16
- /** The default terrain paint palette (surface id → color) shared by the panel and the mesh. */
17
- export declare const TERRAIN_MATERIALS: readonly TerrainMaterial[];
18
- /** Maps every default material id to its render color, for the sculpt mesh's per-cell surface tint. */
19
- export declare const TERRAIN_MATERIAL_COLORS: Record<string, string>;
10
+ export { TERRAIN_MATERIALS, TERRAIN_MATERIAL_COLORS, type TerrainMaterial } from "@jgengine/core/editor/terrainMaterials";
20
11
  /** Live terrain material-paint controls driven by the terrain tool panel. */
21
12
  export interface PaintSettings {
22
13
  material: string;
package/dist/uiStore.js CHANGED
@@ -1,16 +1,4 @@
1
- /** The default terrain paint palette (surface id → color) shared by the panel and the mesh. */
2
- export const TERRAIN_MATERIALS = [
3
- { id: "grass", label: "Grass", color: "#4b7f3f" },
4
- { id: "dirt", label: "Dirt", color: "#6b4f34" },
5
- { id: "rock", label: "Rock", color: "#7c7f86" },
6
- { id: "sand", label: "Sand", color: "#c2b283" },
7
- { id: "mud", label: "Mud", color: "#574433" },
8
- { id: "snow", label: "Snow", color: "#eef3f7" },
9
- { id: "road", label: "Road", color: "#3a3a3d" },
10
- { id: "gravel", label: "Gravel", color: "#8d8d84" },
11
- ];
12
- /** Maps every default material id to its render color, for the sculpt mesh's per-cell surface tint. */
13
- export const TERRAIN_MATERIAL_COLORS = Object.fromEntries(TERRAIN_MATERIALS.map((material) => [material.id, material.color]));
1
+ export { TERRAIN_MATERIALS, TERRAIN_MATERIAL_COLORS } from "@jgengine/core/editor/terrainMaterials";
14
2
  /** The terrain tool's default paint controls. */
15
3
  export const DEFAULT_PAINT_SETTINGS = {
16
4
  material: "grass",
@@ -0,0 +1,20 @@
1
+ import type { EditorSession } from "@jgengine/core/editor/commands";
2
+ /** Document save lifecycle owned by the mounted authoring session, across viewport modes. */
3
+ export interface EditorDocumentSave {
4
+ available: boolean;
5
+ dirty: boolean;
6
+ saveState: "idle" | "saving" | "saved" | "error";
7
+ saveError: string | null;
8
+ lastSavedAt: number | null;
9
+ doSave(): void;
10
+ }
11
+ /** Save status shared with editor chrome without changing the viewport component identity. @internal */
12
+ export declare const EditorDocumentSaveContext: import("react").Context<EditorDocumentSave | undefined>;
13
+ type SaveFn = (json: string) => Promise<{
14
+ ok: boolean;
15
+ path?: string;
16
+ error?: string;
17
+ }>;
18
+ /** Keep the acknowledged document and pending/error status alive while editor chrome unmounts. @internal */
19
+ export declare function useDocumentSave(session: EditorSession, save: SaveFn | undefined, onResult?: (ok: boolean, detail: string) => void): EditorDocumentSave;
20
+ export {};
@@ -0,0 +1,33 @@
1
+ import { createContext, useCallback, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
+ /** Save status shared with editor chrome without changing the viewport component identity. @internal */
3
+ export const EditorDocumentSaveContext = createContext(undefined);
4
+ /** Keep the acknowledged document and pending/error status alive while editor chrome unmounts. @internal */
5
+ export function useDocumentSave(session, save, onResult) {
6
+ const store = useMemo(() => ({ subscribe: (listener) => session.subscribe(listener), getSnapshot: () => session.getState().document }), [session]);
7
+ const document = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
8
+ const [status, setStatus] = useState(() => ({ document, state: "idle", error: null, at: null }));
9
+ const currentJson = useMemo(() => JSON.stringify(document), [document]);
10
+ const savedJson = useMemo(() => JSON.stringify(status.document), [status.document]);
11
+ const pending = useRef(false);
12
+ const result = useRef(onResult);
13
+ result.current = onResult;
14
+ const doSave = useCallback(() => {
15
+ if (save === undefined || pending.current)
16
+ return;
17
+ const savedDocument = session.getState().document;
18
+ const json = session.exportJson(true);
19
+ pending.current = true;
20
+ setStatus((previous) => ({ ...previous, state: "saving" }));
21
+ void Promise.resolve().then(() => save(json)).then((value) => {
22
+ if (!value.ok)
23
+ throw new Error(value.error ?? "save failed");
24
+ setStatus({ document: savedDocument, state: "saved", error: null, at: Date.now() });
25
+ result.current?.(true, value.path === undefined ? "Scene saved" : `Scene saved to ${value.path}`);
26
+ }).catch((failure) => {
27
+ const message = failure instanceof Error ? failure.message : String(failure);
28
+ setStatus((previous) => ({ ...previous, state: "error", error: message }));
29
+ result.current?.(false, `Save failed: ${message}`);
30
+ }).finally(() => { pending.current = false; });
31
+ }, [session, save]);
32
+ return { available: save !== undefined, dirty: currentJson !== savedJson, saveState: status.state, saveError: status.error, lastSavedAt: status.at, doSave };
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/editor",
3
- "version": "0.19.0",
3
+ "version": "0.22.0",
4
4
  "description": "Scene/world/asset editor for JGengine — loaded lazily by the runner; never import from game entrypoints.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -31,10 +31,10 @@
31
31
  "test": "bun test src"
32
32
  },
33
33
  "dependencies": {
34
- "@jgengine/core": "^0.19.0",
35
- "@jgengine/navbake": "^0.19.0",
36
- "@jgengine/react": "^0.19.0",
37
- "@jgengine/shell": "^0.19.0"
34
+ "@jgengine/core": "^0.22.0",
35
+ "@jgengine/navbake": "^0.22.0",
36
+ "@jgengine/react": "^0.22.0",
37
+ "@jgengine/shell": "^0.22.0"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@react-three/drei": "^10.0.0",
@@ -46,6 +46,7 @@
46
46
  "devDependencies": {
47
47
  "@react-three/drei": "^10.7.7",
48
48
  "@react-three/fiber": "^9.5.0",
49
+ "@types/node": "^24",
49
50
  "@types/react": "^19",
50
51
  "@types/react-dom": "^19",
51
52
  "@types/three": "^0.182.0",
@@ -25,7 +25,7 @@ Create one immutable handoff at the destination the user requested, or use the m
25
25
  ### Build the handoff
26
26
 
27
27
  1. Distill the current objective and the user's latest intent. If a focus was supplied, make it the `resume_focus`.
28
- 2. Inspect only the workspace state needed to explain what exists now. Use JGengine project instructions already in context (`AGENTS.md` / `CLAUDE.md`, `workflow`, domain skills).
28
+ 2. Inspect only the workspace state needed to explain what exists now. Use JGengine project instructions already in context (`AGENTS.md`, `workflow`, domain skills).
29
29
  3. Point to plans, issues, commits, diffs, documentation, and relevant files instead of reproducing their contents. Prefer repository-relative paths (`docs/plans/…`, `packages/…`, `Games/…`).
30
30
  4. Redact secrets, credentials, tokens, and unrelated personal information. Preserve operational paths only when the next agent needs them.
31
31
  5. Write or publish with existing tools. If the user named another path, folder, format, or publication destination, honor it. Do not also create a managed-store copy unless asked.
@@ -9,6 +9,22 @@ This skill decides what to load; it is not an engine manual. Package map, comman
9
9
 
10
10
  A created project installs only `jgengine`, `jgengine-editor`, `jgengine-verify`, `game-design`, and `jgengine-ui`. Run `npx jgengine skills -p --all` before loading any other domain named below. Those projects do not ship `api.md`; read exact signatures from the `.d.ts` files under `node_modules/@jgengine/<pkg>/dist/`.
11
11
 
12
+ Created projects pin their scaffolding CLI as a local devDependency, separately from SDK versions. Run `npx jgengine doctor` after SDK upgrades to check CLI compatibility. Skill installation preserves newer or differing unversioned copies; update the project CLI first, and use `skills -p --force` only when replacement is intended. Each packaged skill install records its CLI and SDK versions in `.jgengine-version.json`.
13
+
14
+ ## Framework-first decisions
15
+
16
+ For every requested interaction, feature, fix or visual change, follow the boundary in
17
+ [AGENTS.md](../../../AGENTS.md): discover the installed
18
+ capability, adopt it, or improve its shared owner before adding reusable game-local code.
19
+ Games keep unique rules, content, assets and presentation. Custom markup is not a reason
20
+ to copy focus handling, input tracking, storage or model lifecycle.
21
+
22
+ Check installed versions before recreating an upstream fix. A published upgrade may
23
+ replace a workaround; a beta merge does not update consumers. In the games workspace,
24
+ the root catalog owns SDK versions. After adoption, remove superseded local machinery
25
+ and verify the actual game. Outside these repositories, report missing SDK contracts
26
+ upstream rather than treating a local engine implementation as the finished solution.
27
+
12
28
  ## 1. Establish the target
13
29
 
14
30
  For greenfield builds, write `src/art-direction.md` before any code.
@@ -26,6 +42,10 @@ Pick the dimension first: `create` defaults to a 3D world (terrain, sky, player
26
42
 
27
43
  For an existing game asked to pick up a new engine release ("jgengine published a new version, grab it"), run `npx jgengine upgrade` in the project first: it diffs installed `@jgengine/*` versions against the latest release and prints every Migrate step and Adopt-worthy addition in between (`--json` for structured output). Bump, migrate oldest-first, then work the Adopt lists instead of reconstructing the changelog by hand.
28
44
 
45
+ `doctor` and `upgrade` resolve Bun `catalog:` and `catalog:<name>` declarations from the nearest workspace's `workspaces.catalog` or `workspaces.catalogs`, and follow hoisted SDK installs. Missing or malformed catalog entries fail diagnostics. `upgrade` is read-only by default. For catalog-owned versions, run `upgrade --plan --to <published-version>` at the workspace root or in a game, review the whole root catalog plan and migrations, then run `upgrade --apply --to <published-version>`. Apply preserves per-game `catalog:` declarations, unrelated text, CLI/github versions, and a held editor override; it verifies published SDK targets and refuses files changed while planning. Apply changes declarations only: run `bun install` at the root, then `jgengine doctor --workspace --json` once there to check installed SDK identities across declared workspace owners. Rebuild, migrate, and verify game behavior afterward. `--json` includes exact before/after text; an apply prints its plan before writing and reports write status on stderr. A report without installed packages labels its resolved pins as a declared baseline.
46
+
47
+ When a linked consumer renders differently despite matching root SDK versions, `doctor` checks direct consumers and SDK dependency/peer paths, including private copies with the same version. Review the reported importer and real paths, align the shared catalog and linked-consumer declarations, then reinstall at the root and rerun `doctor --workspace`; a root install may leave private linked-package installs intact. Restart running games after reinstall. Workspace mode checks installed identities only, reports coverage, and refuses unsupported patterns, escaped symlinks or exhausted traversal budgets. The default project audit stays scoped; neither mode covers custom bundler aliases or indirect non-SDK chains.
48
+
29
49
  ## 2. Select domains
30
50
 
31
51
  Load only rows the target needs. Do not preload every domain.
@@ -33,6 +53,7 @@ Load only rows the target needs. Do not preload every domain.
33
53
  | Need | Skill | Owner |
34
54
  | --- | --- | --- |
35
55
  | player promise, pillars, verbs, loops, economy, progression, balance | `game-design` | experience and rule intent |
56
+ | substantial existing-game improvement across depth, world and presentation | `game-improvement` | ordered improvement plan and per-game completion |
36
57
  | metrics, topology, encounters, navigation, spatial pacing | `level-design` | playable space intent |
37
58
  | scene placement, terrain painting, paths, asset authoring | `jgengine-editor` | editor document and authoring tools |
38
59
  | runtime world, movement, input, interaction, AI, navigation, audio | `jgengine-world` | spatial simulation and world runtime |
@@ -49,12 +70,12 @@ World content routes editor-first: any request that adds, moves, restyles, or re
49
70
  Within each selected domain:
50
71
 
51
72
  0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code) (`combat-loop`, `loot`, `world-drops`, `click-target`, `ability-bar`, `hitscan-weapon`, `rolled-gear`, `enter-vehicle` and more); `npx jgengine recipe` lists them. Copy that instead of reconning per-file.
52
- 1. Search capabilities by intent; do not read `capabilities.md` top to bottom. `npx jgengine find <intent>` searches every domain's index and every recipe at once, installed or not, and prints the primitive and its import (or the recipe to open); in the monorepo, `grep -i "<intent>" .claude/skills/*/capabilities.md` does the same. Search the moment you are tempted to hand-roll a HUD window, inventory grid, paperdoll, hotkey listener, minimap, stat bar, or character motion. A `z-index`, a `keydown` for a panel, a `<div>` inventory, or a limb bob almost certainly has a shipped drop-in (`usePanels`/`PanelHost`, `InventoryGrid`, `CharacterSheet`/`Paperdoll`, `PartMotionRig`, `EntityPreview`); games own the *skin*, not the re-derivation.
73
+ 1. Search capabilities by intent; do not read `capabilities.md` top to bottom. `npx jgengine find <intent>` searches every domain's index and every recipe at once, installed or not, and prints the primitive and its import (or the recipe to open); agents can use `--json --limit 5` for ranked imports and recipes with total, partial-match, and truncation fields. In the monorepo, `rg -i "<intent>" .claude/skills/*/capabilities.md` does the same search. Search the moment you are tempted to hand-roll a HUD window, inventory grid, paperdoll, hotkey listener, minimap, stat bar, or character motion. A `z-index`, a `keydown` for a panel, a `<div>` inventory, or a limb bob almost certainly has a shipped drop-in (`usePanels`/`PanelHost`, `InventoryGrid`, `CharacterSheet`/`Paperdoll`, `PartMotionRig`, `EntityPreview`); games own the *skin*, not the re-derivation.
53
74
  2. Open `api.md` (monorepo) or the package `.d.ts` only when exact signatures are needed; grep it, never read it whole.
54
75
  3. Open the linked reference only for a deeper recipe or trap.
55
76
  4. For how several primitives wire into a running loop, read the domain's `recipes/` — connected, genre-free walkthroughs organized by composition seam; this skill's own [recipes/minimal-game.md](recipes/minimal-game.md) is the default whole-game walkthrough. Prefer a recipe over reading a game's source; the games under `Games/*` are not templates or references, and a hybrid game is just a different composition of the same primitives.
56
77
 
57
- If no capability fits, identify the upstream package seam before writing game-local code. In this repository, implement a reusable primitive with its first adopter; if editor-owned content cannot be expressed, file the editor gap first.
78
+ If no capability fits, identify the upstream package seam before writing game-local code. In this repository, implement a reusable primitive with its first adopter and a reviewed consumer cleanup; coordinate a differently composed second consumer when applicable. Recipes wire SDK APIs, not copied implementations. If editor-owned content cannot be expressed, file the editor gap first; track any fallback and its removal path.
58
79
 
59
80
  ## 4. Build and finish
60
81
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
8
8
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
9
- - `VERSION` (const): const VERSION: "0.19.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.22.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -232,7 +232,7 @@
232
232
 
233
233
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
234
234
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
235
- - `VERSION` (const): const VERSION: "0.19.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
235
+ - `VERSION` (const): const VERSION: "0.22.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
236
236
 
237
237
  ## @jgengine/core/runtime/adapter
238
238
 
@@ -260,10 +260,20 @@
260
260
  ## @jgengine/core/runtime/cameraDirector
261
261
 
262
262
  - `CHASE_VIEWS` (const): const CHASE_VIEWS: readonly ChaseView[] — Default order {@link nextChaseView} cycles through.
263
- - `CameraDirector` (interface): interface CameraDirector { follow(entityId: string | null): void; followedEntityId(): string | null | undefined; setCinematic(config: CinematicCameraConfig | null): void; cinematic(): CinematicCameraConfig | null; setChaseTuning(tuning: ChaseCameraTuning | null): v… — ⚠ undocumented
264
- - `ChaseCameraTuning` (type): type ChaseCameraTuning = Partial< Pick< ChaseCameraConfig, | "distance" | "height" | "lookHeight" | "springDamping" | "fov" | "lead" | "bank" | "shakePerSpeed" | "velocityYaw" | "yawResponse" | "distanceBySpeed" | "pitchFollow" | "fovKick" | "lookBackAction" | "collision" | "view" > > — Runtime patch over the static `camera.chase` config — distance/height/fov retuning from gameplay events (#286.11), a whole driving-feel overlay applied only while a vehicle is piloted (#1299), or a `view` switch between chase and seat cameras.
263
+ - `CameraDirector` (interface): interface CameraDirector { follow(entityId: string | null): void; followedEntityId(): string | null | undefined; setRig(kind: CameraRigKind | null, config?: CameraRigConfig): void; rig(): CameraRigOverride | null; setCinematic(config: CinematicCameraConfig | null):… — ⚠ undocumented
264
+ - `CameraDirectorSnapshot` (type): type CameraDirectorSnapshot = CameraSerialized<CameraDirectorState> — JSON-safe camera policy; documented instant chase responses use an explicit Infinity marker.
265
+ - `CameraDirectorState` (interface): interface CameraDirectorState — Camera policy state before the JSON-safe snapshot number encoding.
266
+ - `CameraRigConfig` (type): type CameraRigConfig = Omit<GameCameraConfig, "rig" | "projection" | "pixelPerfect" | "frustum" | "playerFov" | "weapon" | "onCameraFollow"> — Data overlay for an existing rig; callbacks and Canvas projection/preferences remain configured by the game.
267
+ - `CameraRigOverride` (interface): interface CameraRigOverride — Runtime rig selection and detached data overlay over the configured camera.
268
+ - `ChaseCameraTuning` (type): type ChaseCameraTuning = Partial< Pick< ChaseCameraConfig, | "distance" | "height" | "lookHeight" | "springDamping" | "fov" | "lead" | "bank" | "shakePerSpeed" | "velocityYaw" | "yawResponse" | "headingSource" | "distanceBySpeed" | "pitchFollow" | "fovKick" | "lookBackAction" | "collision" | "view" … — Runtime patch over the static `camera.chase` config — distance/height/fov retuning from gameplay events (#286.11), a whole driving-feel overlay applied only while a vehicle is piloted (#1299), or a `view` switch between chase and seat cameras.
265
269
  - `nextChaseView` (function): function nextChaseView(current: ChaseView, views: readonly ChaseView[] = CHASE_VIEWS): ChaseView — The view after `current` in `views`, wrapping at the end; a view missing from the list starts the cycle over. Pair with `setChaseTuning` to bind a "change camera" key: `ctx.camera.setChaseTuning({ ...ctx.camera.chaseTuning(), view: nextChaseView(view) })`.
266
270
 
271
+ ## @jgengine/core/runtime/commandDispatch
272
+
273
+ - `DispatchedCommandResult` (type): type DispatchedCommandResult = CommandResult<GameContext> | Promise<CommandResult<GameContext>> — A UI command completes locally or after its authoritative host acknowledges it.
274
+ - `bindCommandTransport` (function): function bindCommandTransport(ctx: GameContext, route: Route): () => void — Bind UI command dispatch to the host for this context. A missing join rejects; it never mutates the local replica.
275
+ - `dispatchCommand` (function): function dispatchCommand(ctx: GameContext, name: string, input: unknown): DispatchedCommandResult — Dispatch a player's command through the bound host, or locally for an unbound offline context. Await the result before showing success.
276
+
267
277
  ## @jgengine/core/runtime/commandInput
268
278
 
269
279
  - `QuantityResult` (type): type QuantityResult = { ok: true; quantity: number } | { ok: false; reason: "invalid-quantity" } — Validated integer quantity or a stable input rejection.
@@ -318,7 +328,7 @@
318
328
  - `GameContextWorld` (interface): interface GameContextWorld { ground: TerrainField; groundHeightAt(x: number, z: number): number; solids: WorldSolids } — ⚠ undocumented
319
329
  - `HitReactionInput` (interface): interface HitReactionInput — ⚠ undocumented · used by `resolveHitReaction` (@jgengine/core/combat): Resolves hit feel (hitstop, knockback impulse, camera shake) from either a named `impactPresets` event (`resolveHitReaction("explosion", inp…
320
330
  - `MoveTowardCommitOptions` (interface): interface MoveTowardCommitOptions extends MoveTowardOptions — Options for {@link SceneEntityContext.moveTowardCommit}: {@link MoveTowardOptions} plus an optional facing turn.
321
- - `SceneEntityContext` (interface): interface SceneEntityContext { spawn(name: string, options?: SpawnOptions): string; despawn(instanceId: string): boolean; update: EntityStore["update"]; setPose(instanceId: string, pose: EntityPose): boolean; setPoseConstraint: EntityStore["setPoseConstraint"]; get(ins… — ⚠ undocumented
331
+ - `SceneEntityContext` (interface): interface SceneEntityContext { spawn(name: string, options?: SpawnOptions): string; despawn(instanceId: string): boolean; update: EntityStore["update"]; setPose(instanceId: string, pose: EntityPose): boolean; setVelocity: EntityStore["setVelocity"]; setPoseConstraint: … — ⚠ undocumented
322
332
  - `SceneObjectContext` (interface): interface SceneObjectContext extends ObjectStore { catalog(instanceId: string): GameContextObjectEntry | null; raycast(input: ObjectRaycastInput): ObjectRaycastHit | null; raycastAll(input: ObjectRaycastInput): readonly ObjectRaycastHit[]; setColliders(instanceId: string, colliders: Entit… — ⚠ undocumented
323
333
  - `SceneWorldItemContext` (interface): interface SceneWorldItemContext — `ctx.scene.worldItem`: items lying in the world — spawn, list, find the nearest, and pick up into a player's bag.
324
334
  - `TelegraphInput` (interface): interface TelegraphInput { from: string; shape: TelegraphShape; at: [number, number, number]; dir?: number; windupMs: number; kind?: string; effect?: {effect: string; via?: EffectVia; radius?: number; falloff?: "linear" | "none"; los?: boolean;} } — ⚠ undocumented
@@ -349,7 +359,7 @@
349
359
  - `GameContextWorld` (interface): interface GameContextWorld { ground: TerrainField; groundHeightAt(x: number, z: number): number; solids: WorldSolids } — ⚠ undocumented
350
360
  - `HitReactionInput` (interface): interface HitReactionInput { from: string; to: string; config: HitReactionConfig | ImpactPresetName; power?: number } — ⚠ undocumented · used by `resolveHitReaction` (@jgengine/core/combat): Resolves hit feel (hitstop, knockback impulse, camera shake) from either a named `impactPresets` event (`resolveHitReaction("explosion", inp…
351
361
  - `MoveTowardCommitOptions` (interface): interface MoveTowardCommitOptions extends MoveTowardOptions — Options for {@link SceneEntityContext.moveTowardCommit}: {@link MoveTowardOptions} plus an optional facing turn.
352
- - `SceneEntityContext` (interface): interface SceneEntityContext { spawn(name: string, options?: SpawnOptions): string; despawn(instanceId: string): boolean; update: EntityStore["update"]; setPose(instanceId: string, pose: EntityPose): boolean; setPoseConstraint: EntityStore["setPoseConstraint"]; get(ins… — ⚠ undocumented
362
+ - `SceneEntityContext` (interface): interface SceneEntityContext { spawn(name: string, options?: SpawnOptions): string; despawn(instanceId: string): boolean; update: EntityStore["update"]; setPose(instanceId: string, pose: EntityPose): boolean; setVelocity: EntityStore["setVelocity"]; setPoseConstraint: … — ⚠ undocumented
353
363
  - `SceneObjectContext` (interface): interface SceneObjectContext extends ObjectStore { catalog(instanceId: string): GameContextObjectEntry | null; raycast(input: ObjectRaycastInput): ObjectRaycastHit | null; raycastAll(input: ObjectRaycastInput): readonly ObjectRaycastHit[]; setColliders(instanceId: string, colliders: Entit… — ⚠ undocumented
354
364
  - `SceneWorldItemContext` (interface): interface SceneWorldItemContext — `ctx.scene.worldItem`: items lying in the world — spawn, list, find the nearest, and pick up into a player's bag.
355
365
  - `TelegraphInput` (interface): interface TelegraphInput { from: string; shape: TelegraphShape; at: [number, number, number]; dir?: number; windupMs: number; kind?: string; effect?: {effect: string; via?: EffectVia; radius?: number; falloff?: "linear" | "none"; los?: boolean;} } — ⚠ undocumented
@@ -420,7 +430,7 @@
420
430
  - `HostedGameRunner` (interface): interface HostedGameRunner — The GameContext-loop equivalent of the pure-reducer `createGameHost`: one authoritative `createGameContext` per world, driven server-side. `onInit` runs once at construction; `onNewPlayer`/`onPlayerLeave` fire per join/leave; `tick` advances `onTick` then commits a revision. Clients pull a full {@link WorldSnapshot} baseline once, then per-tick {@link WorldDiff}s from their last revision. Games ship only normal GameContext code — the runner adds no per-game surface.
421
431
  - `HostedGameRunnerOptions` (interface): interface HostedGameRunnerOptions<TAssetRef extends ModelAssetRef, TMultiplayer> — Config for {@link createHostedGameRunner}: the game definition, its content lookup, and an optional host identity.
422
432
  - `INPUT_COMMAND` (const): const INPUT_COMMAND: "engine.input" — Reserved command name the authoritative host intercepts on the existing `runCommand` transport to route a client's {@link InputFrame} to `session.input`, so per-tick input needs no separate wire.
423
- - `InputFrame` (interface): interface InputFrame — One client's input for a tick — the semantic held-action set plus pointer state, the serializable, over-the-wire counterpart of {@link InputSnapshot} the host stores per connected player.
433
+ - `InputFrame` (interface): interface InputFrame — One client's serializable held state and discrete edges for a simulation tick.
424
434
 
425
435
  ## @jgengine/core/runtime/hostedWorldSession
426
436
 
@@ -429,7 +439,7 @@
429
439
  - `HostedWorldSessionOptions` (interface): interface HostedWorldSessionOptions<TAssetRef extends ModelAssetRef, TMultiplayer> — Config for {@link createHostedWorldSession}: the game, its persistence store, and the auto-save cadence.
430
440
  - `HostedWorldStore` (interface): interface HostedWorldStore — Narrow persistence seam for a hosted world — the {@link HostedWorldRecord} counterpart of `HostPersistence`. Backends implement it (memory/file/sql/convex); the session never names one. A stateful host loads once and saves on a cadence; a stateless host reconstructs from `load()` each invocation.
431
441
  - `HostedWorldSync` (type): type HostedWorldSync = | { kind: "baseline"; revision: number; snapshot: WorldSnapshot } | { kind: "diff"; diff: WorldDiff } — A client replication pull: a full baseline (first sync / fell behind) or a diff since the client's cursor.
432
- - `SyncHostedWorldStore` (interface): interface SyncHostedWorldStore — Synchronous store adapter retained for deterministic in-process tests.
442
+ - `SyncHostedWorldStore` (interface): interface SyncHostedWorldStore — Already-loaded store adapter: synchronous reads, with synchronous or asynchronous writes.
433
443
  - `asyncMemoryWorldStore` (function): function asyncMemoryWorldStore(seed?: HostedWorldRecord): HostedWorldStore — Async in-process store for callers exercising the production persistence contract.
434
444
  - `createHostedWorldSessionAsync` (function): function createHostedWorldSessionAsync<TAssetRef extends ModelAssetRef, TMultiplayer>(options: Omit<HostedWorldSessionOptions<TAssetRef, TMultiplayer>, "store"> & { store: HostedWorldStore }): Promise<HostedWorldSession> — Build a hosted session from an asynchronous persistence backend.
435
445
 
@@ -437,7 +447,7 @@
437
447
 
438
448
  - `HostedWorldRecord` (interface): interface HostedWorldRecord — One hosted world's persisted authoritative state — the unit a {@link HostedWorldStore} loads and saves.
439
449
  - `HostedWorldStore` (interface): interface HostedWorldStore — Narrow persistence seam for a hosted world — the {@link HostedWorldRecord} counterpart of `HostPersistence`. Backends implement it (memory/file/sql/convex); the session never names one. A stateful host loads once and saves on a cadence; a stateless host reconstructs from `load()` each invocation.
440
- - `SyncHostedWorldStore` (interface): interface SyncHostedWorldStore — Synchronous store adapter retained for deterministic in-process tests.
450
+ - `SyncHostedWorldStore` (interface): interface SyncHostedWorldStore — Already-loaded store adapter: synchronous reads, with synchronous or asynchronous writes.
441
451
 
442
452
  ## @jgengine/core/runtime/inputRecorder
443
453
 
@@ -448,8 +458,9 @@
448
458
 
449
459
  ## @jgengine/core/runtime/inputSnapshot
450
460
 
451
- - `InputFrame` (interface): interface InputFrame — One client's input for a tick — the semantic held-action set plus pointer state, the serializable, over-the-wire counterpart of {@link InputSnapshot} the host stores per connected player.
452
- - `InputSnapshot` (interface): interface InputSnapshot { rumble(userId: string, options: {strong: number; weak: number; ms: number}): Promise<boolean>; haptics(userId: string): HapticChannels; publish(held: readonly string[]): void; publishPointer(state: PointerAxisState | null): void; publishA… — ⚠ undocumented · used by `GamepadSource` (@jgengine/shell/input/gamepadSource): Poll browser gamepads and feed semantic actions into the shell tracker.
461
+ - `InputFrame` (interface): interface InputFrame — One client's serializable held state and discrete edges for a simulation tick.
462
+ - `InputPress` (interface): interface InputPress — One discrete press, identified across retransmitted live input frames.
463
+ - `InputSnapshot` (interface): interface InputSnapshot { rumble(userId: string, options: {strong: number; weak: number; ms: number}): Promise<boolean>; haptics(userId: string): HapticChannels; publish(held: readonly string[], edges?: {pressed?: readonly string[]; reset?: boolean}): void; beginS… — ⚠ undocumented · used by `GamepadSource` (@jgengine/shell/input/gamepadSource): Poll browser gamepads and feed semantic actions into the shell tracker.
453
464
  - `createInputSnapshot` (function): function createInputSnapshot(): InputSnapshot — ⚠ undocumented
454
465
 
455
466
  ## @jgengine/core/runtime/localPlayers
@@ -509,11 +520,11 @@
509
520
 
510
521
  ## @jgengine/core/runtime/runtimeSave
511
522
 
512
- - `RuntimeSave` (interface): interface RuntimeSave — Whole-world save/load bound to a live world and a pluggable backend. `save()` captures `target.snapshot()` and writes it; `load()` reads it back and `target.hydrate()`s the whole world. In `autosave` mode it also writes on a trailing timer while the world keeps changing. Named slots, versioned migration, and offline↔cloud (backend swap) all come for free from the underlying save store.
523
+ - `RuntimeSave` (interface): interface RuntimeSave — Whole-world save/load bound to a live world and a pluggable backend. `save()` captures `target.state()` and writes it; `load()` reads it back and calls `target.restore()`. Older targets use `snapshot()`/`hydrate()`. In `autosave` mode it also writes on a trailing timer while the world keeps changing. Named slots, versioned migration, and offline↔cloud (backend swap) all come for free from the underlying save store.
513
524
  - `RuntimeSaveConfig` (interface): interface RuntimeSaveConfig — How {@link createRuntimeSave} is wired — the live world `target`, the `backend` it persists through, and optional mode/slot/versioning/cadence knobs.
514
525
  - `RuntimeSaveMode` (type): type RuntimeSaveMode = "autosave" | "manual" — `"autosave"` writes a fresh capture on a trailing timer while the world changes (at most once per `autosaveMs`, so a never-idle world still persists); `"manual"` writes only on an explicit `save()`/`checkpoint()` — the save-point / quest-trigger model.
515
526
  - `RuntimeSaveOptions` (type): type RuntimeSaveOptions = Omit<RuntimeSaveConfig, "target"> — {@link RuntimeSaveConfig} without `target` — what a host (`createGameContext`) accepts to build `ctx.game.save` and bind it to the context itself.
516
- - `RuntimeSaveTarget` (interface): interface RuntimeSaveTarget — The narrow slice of a live `GameContext` a runtime save reads and writes — a `GameContext` satisfies it directly (`ctx.snapshot`/`ctx.hydrate`/`ctx.subscribe`). Depending on this instead of the full context keeps the save controller a deep, decoupled module: it captures and restores the *whole* opted-in world without knowing any subsystem.
527
+ - `RuntimeSaveTarget` (interface): interface RuntimeSaveTarget — The narrow slice of a live `GameContext` a runtime save reads and writes — a `GameContext` satisfies it directly (`ctx.state`/`ctx.restore`/`ctx.subscribe`). Depending on this instead of the full context keeps the save controller a deep, decoupled module: it captures and restores the *whole* opted-in world without knowing any subsystem.
517
528
  - `createRuntimeSave` (function): function createRuntimeSave(config: RuntimeSaveConfig): RuntimeSave — Bridge a live world to a pluggable save backend: whole-state capture/restore with autosave, save points, named slots, and versioned migration — the same call for offline (localStorage) and cloud (Convex), only the `backend` differs. The game keeps control of *when* it saves (the `mode` plus explicit `checkpoint()` calls from quests/areas) and *when* it restores (`load()` on boot), so any save mechanic — continuous autosave, manual save points, checkpoint triggers — is expressible without new engine code.
518
529
 
519
530
  ## @jgengine/core/runtime/save
@@ -527,7 +538,7 @@
527
538
  - `SimContextState` (interface): interface SimContextState — Serializable `ctx.sim` state: loop position plus the interpolation pose history.
528
539
  - `SimStage` (interface): interface SimStage — Extra work a game or engine seam (a physics backend, a netcode buffer) runs inside every simulation step.
529
540
  - `SimStagePhase` (type): type SimStagePhase = "beforeMovement" | "afterMovement" | "afterTick" — Where a registered stage runs inside one simulation step.
530
- - `createSimContext` (function): function createSimContext(options: { config?: SimulationConfig; entities: EntityStore }): SimContext — Build `ctx.sim` for a context; `createGameContext` calls this from `definition.simulation`.
541
+ - `createSimContext` (function): function createSimContext(options: { config?: SimulationConfig; entities: EntityStore; time?: Pick<SimClock, "advance"> }): SimContext — Build `ctx.sim` for a context; `createGameContext` calls this from `definition.simulation`.
531
542
 
532
543
  ## @jgengine/core/runtime/simLoop
533
544
 
@@ -640,7 +651,7 @@
640
651
  ## @jgengine/shell/GameHost
641
652
 
642
653
  - `EditorSummonModule` (interface): interface EditorSummonModule — Structural shape of the module `GameHost`'s `editor` loader resolves — `import("@jgengine/editor")` satisfies it.
643
- - `GameHost` (function): function GameHost({ playable, gameId, wsUrl, multiplayer, resolveMultiplayer, editor }: GameHostProps): React.JSX.Element — The one documented mount: resolves multiplayer for the playable and renders the shell. With the `editor` loader prop it also owns the whole editor summon (F2+E, `?mode=editor`, dev save endpoint).
654
+ - `GameHost` (function): function GameHost({ playable, gameId, wsUrl, multiplayer, resolveMultiplayer, editor, creator, creatorOpen = false, onCreatorOpenChange }: GameHostProps): React.JSX.Element — The one documented mount: resolves multiplayer for the playable and renders the shell. With the `editor` loader prop it also owns the whole editor summon (F2+E, `?mode=editor`, dev save endpoint).
644
655
  - `GameHostProps` (interface): interface GameHostProps — Props for {@link GameHost}: the playable, optional multiplayer overrides, and the optional editor loader that enables the engine-owned F2+E summon.
645
656
 
646
657
  ## @jgengine/shell/defineGame
@@ -659,7 +670,7 @@
659
670
  - `GameCameraConfig` (interface): interface GameCameraConfig — Camera tuning for the shell's rig stack: pick the rig via `rig`, then tune it through its matching config block. All fields optional — the default is the third-person orbit rig.
660
671
  - `GameConfig` (type): type GameConfig<TAssetRef extends ModelAssetRef = ModelAssetRef> = EngineFields<TAssetRef> & PresentationFields — ⚠ undocumented · used by `defineGame`: The one public authoring entry point: compose engine fields (systems, world, physics, input) and presentation fields (camera, HUD, audio, au…
661
672
  - `GameContext` (interface): interface GameContext — The live engine handle a game's loop, systems, commands, and UI read and mutate — entities, objects, the reactive store, opted-in `game.*` subsystems, world queries, and the sim clock. One context = one running world.
662
- - `GameHost` (function): function GameHost({ playable, gameId, wsUrl, multiplayer, resolveMultiplayer, editor }: GameHostProps): React.JSX.Element — The one documented mount: resolves multiplayer for the playable and renders the shell. With the `editor` loader prop it also owns the whole editor summon (F2+E, `?mode=editor`, dev save endpoint).
673
+ - `GameHost` (function): function GameHost({ playable, gameId, wsUrl, multiplayer, resolveMultiplayer, editor, creator, creatorOpen = false, onCreatorOpenChange }: GameHostProps): React.JSX.Element — The one documented mount: resolves multiplayer for the playable and renders the shell. With the `editor` loader prop it also owns the whole editor summon (F2+E, `?mode=editor`, dev save endpoint).
663
674
  - `GameHostProps` (interface): interface GameHostProps — Props for {@link GameHost}: the playable, optional multiplayer overrides, and the optional editor loader that enables the engine-owned F2+E summon.
664
675
  - `GameLoop` (interface): interface GameLoop<TContext = unknown> — Lifecycle hooks a game implements to drive init, per-tick simulation, and player join/leave.
665
676
  - `Hotbar` (function): function Hotbar({ inventoryId, activeSlot, keys, slotSize, itemIcon, style, className, }: { inventoryId: string; activeSlot?: number; keys?: readonly string[]; slotSize?: number; /** Caller-supplied item id → icon registry; return null/undefined to fall back to the default glyph. */ itemIcon?: (item… — A numbered hotbar bound to an inventory — painted iconed slots (a `GameIcon` glyph over a school-keyed gradient with a count badge, #1035), an active-slot highlight, and a keycap per slot. Place it and pass the `inventoryId`; `activeSlot` highlights the equipped one. Supply `itemIcon` to map your item ids to your own glyphs — the default resolves a `GameIcon` from the item id.
@@ -673,7 +684,7 @@
673
684
  - `collectAuthoredTriggers` (function): function collectAuthoredTriggers(document: SceneDocumentLike): AuthoredTrigger[] — Collect every authored trigger on a document's markers and volumes. Pure — no runtime state. Action params use the live {@link registerTriggerAction} registry when present.
674
685
  - `defineGame` (function): function defineGame<TAssetRef extends ModelAssetRef = ModelAssetRef>(config: GameConfig<TAssetRef>): PlayableGame — The one public authoring entry point: compose engine fields (systems, world, physics, input) and presentation fields (camera, HUD, audio, authored scene) into a `PlayableGame` ready for `GameHost`. Defaults to solo/offline multiplayer; `editorLayers` auto-mounts the authored scene document.
675
686
  - `defineKeyedStore` (function): function defineKeyedStore<T>(keyFor: (id: string) => string, initial: T | (() => T)): KeyedStoreHandle<T> — Define a typed per-owner keyed family on the game store: game code expresses `read(ctx, userId)`/`write(ctx, userId, v)`/`update(...)` with the value type it means, and both the `` `prefix:${id}` `` key composition and the `unknown → T` cast live once, here, behind the boundary. Reach for this over N separate `defineStore` calls whenever the owning id varies at runtime (per-user class, per-instance auras) — `defineStore` stays the right call for a single fixed slot.
676
- - `defineStore` (function): function defineStore<T>(key: string, initial: T | (() => T)): StoreHandle<T> — Define a typed slot on the game store: game code expresses `read(ctx)`/`write(ctx, v)`/`update(...)` with the value type it means, and the `unknown → T` cast lives once, here, behind the boundary. Pass a factory for `initial` when the fallback is a fresh mutable object; the factory runs at most once and its result is reused, so an unwritten slot keeps a stable identity across reads (no per-read churn for a React selector, no allocation on a hot path).
687
+ - `defineStore` (function): function defineStore<T>(key: string, initial: T | (() => T)): StoreHandle<T> — Define a typed slot on the game store: game code expresses `read(ctx)`/`write(ctx, v)`/`update(...)` with the value type it means, and the `unknown → T` cast lives once, here, behind the boundary. Pass a factory for `initial` when the fallback is a fresh mutable object; the factory runs at most once and its result is reused, so an unwritten slot keeps a stable identity across reads (no per-read churn for a React selector, no allocation on a hot path). A value that carries functions (a closure-based run store, an input handle) is runtime-only: snapshots, saves and replication skip it and hydration keeps it in place. Hold such objects with `perContext` when they need no store notifications.
677
688
  - `defineSystem` (function): function defineSystem(definition: SystemDefinition): SystemDefinition — Declare a composable game system. Pure data + hooks — the engine compiles the schedule and installs lifecycle when the game boots.
678
689
  - `environment` (function): function environment(config?: EnvironmentWorldConfig): EnvironmentWorldFeature — Composes an `environment()` feature from terrain, sky, weather, vegetation, water, structures, roads, and pads.
679
690
  - `environmentContentFromDocument` (function): function environmentContentFromDocument(doc: EditorDocument, options?: EnvironmentContentOptions): EnvironmentContent — Derives the coordinate/placement content of an `environment()` world from its scene document: terrain footprint (via {@link terrainBoundsFromDocument}), ground clearings under authored spawns/POIs (via `clearanceZonesFrom`), and the document's sculpt snapshot. This is the seam that lets a game author its world footprint and flatten regions in the editor instead of hardcoding them — the `world.ts` that consumes it carries only engine tuning.
@@ -6,6 +6,11 @@ authored scene edited in the browser, one system, one store slot, an empty-canva
6
6
  and a win condition. Every new game starts here; add combat, multiplayer, or deeper
7
7
  world features only after this loop plays.
8
8
 
9
+ Every extension follows the [framework-first decision](../SKILL.md#framework-first-decisions):
10
+ compose an installed SDK capability, improve its upstream contract if needed, then
11
+ remove superseded game glue after published adoption. The game owns its rules and art;
12
+ it should not recreate input, model lifecycle, storage or UI accessibility machinery.
13
+
9
14
  ## 1. Scaffold
10
15
 
11
16
  `npx jgengine create <name>` (inside the engine monorepo: `bun run new:game "<id-or-title>"`).
@@ -80,8 +85,8 @@ direction. Compose the shipped building blocks the moment you need them (`StatBa
80
85
  `Coins`, `InventoryGrid`, `CharacterSheet`, window shells from `jgengine-ui`), then reskin via
81
86
  `HudTheme` tokens. Reaching for those is using the engine correctly, not incomplete work. What
82
87
  the engine does not ship is a whole finished game *face* or genre theme preset — do not leave an
83
- unarranged, unskinned generic dashboard as the product. Hand-roll markup only where no building
84
- block fits (see `jgengine-ui`).
88
+ unarranged, unskinned generic dashboard as the product. Custom markup can use headless
89
+ SDK behavior; a missing reusable behavior is an upstream gap (see `jgengine-ui`).
85
90
 
86
91
  ## 3. Author, play, win
87
92
 
@@ -28,6 +28,8 @@ bun packages/editor/src/mcp/cli.ts --game <id> \
28
28
 
29
29
  Repeat `--rpc` to batch several mutations on one session; `--save` then writes the document back to `Games/<id>/src/editor.scene.json` in the same format as the GUI's Ctrl+S, and a failed RPC aborts the batch before anything is saved.
30
30
 
31
+ For a multi-object authoring operation, use `push_document_patch` with a `commands` patch and the current `document_revision` as `baseRevision`. The command batch stages atomically, produces one undo step and one document publication, and reports a zero-based failing command index without leaving earlier edits behind. Use stable caller-owned object ids so later commands can reference earlier placements; same-kind adds upsert, while cross-kind collisions fail. See [reference.md](reference.md#atomic-authoring-batches) for a complete request. Repeated independent `--rpc` calls still produce separate undo steps.
32
+
31
33
  That CLI exists only in the monorepo (`npx jgengine editor-mcp` says so). In a created project the same verbs run on the live page through the agent bridge: `bun run drive -- --rpc '{"method":"editor_summon"}' --rpc '<verb json>' --rpc '{"method":"save_scene"}'`. `editor_summon` resolves once the editor has mounted, so the verbs after it in the same batch reach it.
32
34
 
33
35
  ## Canonical workflow
@@ -40,7 +42,13 @@ That CLI exists only in the monorepo (`npx jgengine editor-mcp` says so). In a c
40
42
 
41
43
  Scaffolded games start editor-wired: `npx jgengine create` (also what `bun run new:game` runs in-repo) uses one template that ships `src/editor.scene.json` + `editorLayers.ts`, passes the document to play mode and F2+E through `defineGame({ editorLayers })` (auto-mounted `AuthoredScene`), and spawns at the authored marker — extend the shipped document instead of re-wiring or hardcoding. To promote a scene authored in the standalone editor (`npx jgengine editor`) into a runnable game, `npx jgengine create "<Name>" --from-scene <folder>` bakes that folder's `editor.scene.json` in as the new game's scene (adding a `player_spawn` at the origin only if the authored scene lacks one), so the built game walks the scene that was just authored. Once promoted, an editor asset import (drop a `.glb`/`.gltf`) no longer scans a model folder: the host detects the typed `src/game/assets.ts` catalog, copies the bytes into the game's `public/<basePath>/imported/`, and rewrites `assets.ts` to add a durable `extras` entry (`buildCatalog({ extras })`), so the shipped game resolves the imported id through its own catalog. The rewrite is idempotent — re-importing the same id collapses to one entry — and falls back to the folder-scan import if `assets.ts` can't be safely rewritten.
42
44
 
43
- Use `capabilities.md` to discover editor/runtime imports and `api.md` for signatures. Open [reference.md](reference.md) for RPC/CLI operations, embedded-agent behavior, scene schema details, and troubleshooting.
45
+ Use `capabilities.md` to discover editor/runtime imports and `api.md` for signatures. Open [reference.md](reference.md) for RPC/CLI operations, embedded-agent behavior, scene schema details, and troubleshooting. Use [live-sync reconnect](references/live-sync.md) when a client resumes beyond retained patch or runtime-delta history.
46
+
47
+ ### Player creation and durable playtests
48
+
49
+ For a production Create/Edit surface, use `GameHost`'s controlled `creatorOpen`/`onCreatorOpenChange` from the game's own menu and pass `creator` configuration with injected `CreatorDocumentStorage`, `CreatorPolicy`, `initialDocument`, and `createPlayable(document)`. `createCreatorDocumentStorage` (`@jgengine/core/editor/creatorStorage`) adapts an injected key/value backend to named versioned saves with optimistic revision checks. Save failure and stale revisions stay visible; reopening loads durable content, independently of developer recovery drafts. The policy bounds documents, bytes, objects, paths, grids, terrain and approved kinds/assets/catalog ids; optional simulation budgets and `validate` apply game rules. Creator documents use explicit placements, without prefab/directive expansion or external asset URLs.
50
+
51
+ Build each playtest through `createPlayable` from the captured current document, including unsaved edits. The editor retains its authored document and undo history, and each playtest gets a fresh shell and runtime live-sync bus. Returning discards that runtime state. The player flow uses normal buttons and does not require the developer global host, keyboard chords, agent RPC or a development save server. See [reference.md](reference.md#production-creator) for the host composition.
44
52
 
45
53
  ## Traps
46
54