Skip to content
2 changes: 1 addition & 1 deletion .claude/skills/jgengine-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Existing React games keep their entity store and use the focused

- **Every game owns its UI end-to-end.** Custom composition, skin, placement, terminology, motion, and one main menu. The website/runner is a bare loader. Composing and reskinning the shipped blocks is correct use; a genre HUD kit, theme preset, or unarranged default stack dropped in as the game's identity is not.
- **The main menu is a game front-end, not marketing.** The runner loads straight into the game's own menu; it offers the real front-end verbs the game supports — New Game, Continue/Load (save slots), Create/Choose character or class, Settings, Credits — each an authored screen in the game's art direction, not a landing page or raw scaffold. See [reference.md](reference.md#main-menu).
- **Menus come from the shipped blocks, navigated by `useMenuNav`.** `TitleMenu`/`MenuScreen` (`@jgengine/react`) give the animated screen over the live scene (transform/opacity only, reduced-motion aware, unmounted after exit), `PauseMenu` the pause shape, `SettingsSection`/`SettingsTabs` the settings surface, `SaveSlotMenu` the slot list, `CreditsPanel` the credits screen, `LoadingScreen` the boot screen with rotating tips. `useMenuNav` (`@jgengine/react/menuNav`) is the one keyboard + gamepad focus model for any menu: roving focus, confirm/back, disabled rows skipped, no focus trap. Games keep wording, layout, art and terminology.
- **Menus come from the shipped blocks, navigated by `useMenuNav`.** `GameMenu` (`@jgengine/react/gameMenu`) is the front-end surface: full-bleed over a live scene or key art, a logo sized by `--jg-menu-logo-size`, animated primary verbs with a generated selection tick, and a detail pane that follows the highlight — with `ContinuePane`, `NewCharacterPane`, `SaveSlotsPane` (cards with thumbnails), `CreditsPane` and the game's own settings surface as its panes, and `GamePauseMenu` the same system sized down. It locks the document scroll, so the surface is exactly the viewport at any size. `TitleMenu`/`MenuScreen` (`@jgengine/react`) remain for a plainer animated screen over the live scene (transform/opacity only, reduced-motion aware, unmounted after exit), `PauseMenu` the small pause shape, `SettingsSection`/`SettingsTabs` the settings surface, `SaveSlotMenu` a bare slot list, `CreditsPanel` the credits screen, `LoadingScreen` the boot screen with rotating tips. `useMenuNav` (`@jgengine/react/menuNav`) is the one keyboard + gamepad focus model for any menu: roving focus, confirm/back, disabled rows skipped, no focus trap. Games keep wording, layout, art and terminology.
- **Settings is game-owned and reachable from a sensible place.** Every game ships settings as *its* settings — skinned and placed inline with its UI (main menu and pause), never the engine's stock face floated into a corner. Credits are a reachable in-game screen, not attribution buried in a build artifact.
- **Route the front-end with `useMenuRouter`, and generate the credits.** `useMenuRouter(root)` is the screen stack behind title → save select → settings → credits: `open`/`back`/`replace`/`reset`, `path` for depth, and Escape-to-back bound by default (`escapeToBack: false` when the game owns Escape). `menuStackReducer` is the same transition as a pure function. For credits, `creditsForSourceIds(ids)` (`@jgengine/assets/credits`) turns the pack ids a game declares to `buildCatalog` into licensed, attributed sections, `mergeCredits` folds them into hand-authored lines, and `CreditsScreen` renders the result HudTheme-skinned.
- **Skin the shared chrome with one `HudTheme` token object.** `hudThemeVars(theme)` emits the whole `--jg-*` vocabulary — bar fills and troughs, `HudFrame variation="themed"`, painted slots, the minimap ring, plus surfaces, edges, text, status colors, fonts, and rarity — so one theme spread on any HUD ancestor reskins the `@jgengine/react` primitives and the `registry/jgengine` blocks together. Author your own with `deriveHudTheme({ accent, surface })` or `hudTheme({ ... })`; `HUD_THEME_PRESETS` / `resolveHudTheme("<preset>")` are demo/scaffold starting points, never a game's identity. A component that needs a token no theme emits fails `check-hud-tokens` — add it to `HudTheme` rather than inventing a second vocabulary. Caller `style`/`renderItem` overrides still win.
Expand Down
68 changes: 68 additions & 0 deletions .claude/skills/jgengine-ui/api.md

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions .claude/skills/jgengine-ui/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p

- `ConfirmDialog` (function) · `import { ConfirmDialog } from "@jgengine/react"`

## continue-pane — continue detail pane — the save being resumed with thumbnail, character, playtime and progress

- `ContinuePane` (function) · `import { ContinuePane } from "@jgengine/react"`

## controller-camera-shake — R3F consumer that applies a core camera-shake controller's pooled per-frame offset to the active camera (translation + pitch/yaw/roll) so the view visibly shakes

- `ControllerCameraShake` (function) · `import { ControllerCameraShake } from "@jgengine/shell/camera"`
Expand All @@ -215,6 +219,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p

- `ControlsList` (function) · `import { ControlsList } from "@jgengine/react"`

## credits-pane — credits detail pane — sections scrolled inside the pane

- `CreditsPane` (function) · `import { CreditsPane } from "@jgengine/react"`

## credits-panel — scrollable, navigable credits screen with a back affordance

- `CreditsPanel` (function) · `import { CreditsPanel } from "@jgengine/react"`
Expand Down Expand Up @@ -341,6 +349,14 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p

- `GameContextBridge` (function) · `import { GameContextBridge } from "@jgengine/react"`

## game-menu — full-bleed game menu — living background, token-sized logo, animated navigable verbs, detail pane, selection sound, pause variant, no page scrollbar

- `GameMenu` (function) · `import { GameMenu } from "@jgengine/react"`

## game-pause-menu — pause menu from the shared game-menu system — same navigation and surface, sized down

- `GamePauseMenu` (function) · `import { GamePauseMenu } from "@jgengine/react"`

## gesture-resume — unlock the audio context on the first pointer/key/touch gesture, so pre-gesture cues play instead of vanishing

- `installGestureResume` (function) · `import { installGestureResume } from "@jgengine/shell/audio/gestureResume"`
Expand Down Expand Up @@ -577,6 +593,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p

- `MenuScreen` (function) · `import { MenuScreen } from "@jgengine/react"`

## menu-sound — generated click for menu move/confirm/back, no audio asset

- `createGameMenuSound` (function) · `import { createGameMenuSound } from "@jgengine/react"`

## menu-stack-reducer — pure push/back/replace/reset transitions for a menu screen stack

- `menuStackReducer` (function) · `import { menuStackReducer } from "@jgengine/react/menuRouter"`
Expand Down Expand Up @@ -647,6 +667,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p

- `WorldNameplates` (function) · `import { WorldNameplates } from "@jgengine/shell/world/WorldHud"`

## new-character-pane — character creation pane — name field, palette and avatar choice, start action

- `NewCharacterPane` (function) · `import { NewCharacterPane } from "@jgengine/react"`

## notice-feed — render ctx.game.notices as an animated toned stack (enter/exit motion, ×N stacking, HUD avoidance)

- `NoticeStack` (function) · `import { NoticeStack } from "@jgengine/react/notices"`
Expand Down Expand Up @@ -785,10 +809,18 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p

- `resolveGameLook` (function) · `import { resolveGameLook } from "@jgengine/core/ui"`

## save-slot-card — save slot as a card — thumbnail, character, playtime, progress, timestamp, confirmed delete

- `SaveSlotCard` (function) · `import { SaveSlotCard } from "@jgengine/react"`

## save-slot-menu — drop-in save-select / profile menu that renders a core save-slots index as New / Continue / Load / Delete cards with free-string meta chips and relative save times, theme-skinnable

- `SaveSlotMenu` (function) · `import { SaveSlotMenu } from "@jgengine/react"`

## save-slots-pane — slot grid pane for the game menu — Continue newest, per-slot cards, keyboard navigation

- `SaveSlotsPane` (function) · `import { SaveSlotsPane } from "@jgengine/react"`

## scene-capture-binding — expose the in-Canvas scene-capture function to outside-Canvas HUD (photo mode)

- `SceneCaptureBinding` (function) · `import { SceneCaptureBinding } from "@jgengine/shell/render/sceneCapture"`
Expand Down Expand Up @@ -845,6 +877,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p

- `ShopGrid` (function) · `import { ShopGrid } from "@jgengine/react"`

## shrink-capture — downscale a captured frame to a save-card thumbnail data URL

- `shrinkCapture` (function) · `import { shrinkCapture } from "@jgengine/react/saveThumbnail"`

## speed-format — render a m/s speed as km/h, mph, knots, or m/s for speedometers and telemetry HUDs

- `formatSpeed` (function) · `import { formatSpeed } from "@jgengine/core/ui"`
Expand Down Expand Up @@ -1019,6 +1055,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p

- `useT` (function) · `import { useT } from "@jgengine/react"`

## use-viewport-scroll-lock — count-aware document scroll lock for full-bleed surfaces

- `useViewportScrollLock` (function) · `import { useViewportScrollLock } from "@jgengine/react"`

## use-wave-runner — React hook that subscribes to a wave runner and returns its pooled current-wave view

- `useWaveRunner` (function) · `import { useWaveRunner } from "@jgengine/react"`
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/jgengine-ui/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ Focus must look authored while remaining accessible. Menus should support keyboa

Compose it from the game's own markup over the headless `StartScreen` (`@jgengine/react`, `start-screen` capability) — a full-bleed `data-jg-menu` overlay that centers your content and offers an opt-in settings corner; it imposes no look. Menu rows are the game's own buttons and focus handling restyled into its chrome; the shipped pieces to compose in are `ControlsList` (`@jgengine/react`) for the control legend and `KeyHint`/`Keycap` for key badges (`KeyHint` already renders nothing on a coarse pointer) — never shipped as raw scaffold.

**The engine ships the front-end machinery; the game owns its look.** `MenuScreen` (`@jgengine/react/menuScreen`) is one animated screen over the live scene: transform/opacity enter and exit that unmount when the exit finishes, a translucent scrim (`"none"` keeps the 3D scene fully visible), and a `backdrop` slot for a poster or preview. `TitleMenu` (`@jgengine/react/titleMenu`) is the title shape — wordmark, eyebrow, tagline, verb list, footer, control hint — where the game supplies the wording, verbs and art and skins it with `--jg-*` tokens. Both read `useMenuNav` (`@jgengine/react/menuNav`): arrow keys/WASD/Home/End, a standard gamepad's d-pad and left stick (with hold-to-repeat), Enter/Space/A confirm, Escape/Backspace/B/Start back. Tab is never intercepted and typing in a field is never swallowed, so no menu traps focus; keys still work if the canvas kept focus. A game that composes its own markup gets the same behavior from `useMenuNav` alone.
**The engine ships the front-end machinery; the game owns its look.** `GameMenu` (`@jgengine/react/gameMenu`) is the game menu: full-bleed `position: fixed` surface with the document scroll locked, a living background (the live scene behind it, a `background` layer, or drifting `art`), a logo sized by `--jg-menu-logo-size`, primary verbs whose highlight slides, glows and ticks (generated WebAudio, no asset), and a detail pane per verb. Ship the panes it exports: `ContinuePane` (the save being resumed), `NewCharacterPane` (name, palette, avatar), `SaveSlotsPane` (cards with a `meta.thumbnail` capture, playtime, progress, timestamp, two-press delete), `CreditsPane` (scroller inside the pane) and the game's real settings surface. `GamePauseMenu` is the same system at `variant="pause"`. Games supply the logo, art, wording, verbs and each pane's content; saves come from `createSaveSlots` + `refreshSaveSlots`, and a thumbnail from `captureCanvas` + `shrinkCapture`. `MenuScreen` (`@jgengine/react/menuScreen`) is one animated screen over the live scene: transform/opacity enter and exit that unmount when the exit finishes, a translucent scrim (`"none"` keeps the 3D scene fully visible), and a `backdrop` slot for a poster or preview. `TitleMenu` (`@jgengine/react/titleMenu`) is the plainer title shape - wordmark, eyebrow, tagline, verb list, footer, control hint. All read `useMenuNav` (`@jgengine/react/menuNav`): arrow keys/WASD/Home/End, a standard gamepad's d-pad and left stick (with hold-to-repeat), Enter/Space/A confirm, Escape/Backspace/B/Start back. Tab is never intercepted and typing in a field is never swallowed, so no menu traps focus; keys still work if the canvas kept focus. A game that composes its own markup gets the same behavior from `useMenuNav` alone.

**The front-end entries, when the game has them.** A menu is more than one Start button. Offer the real front-end verbs a player expects, gated to what the game actually supports:

Expand Down
16 changes: 16 additions & 0 deletions apps/dev/src/appEnv.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test";

/**
* The runner's environment module is read by tests and tools that load the registry, not only by the
* browser app. Outside a browser it must still import and answer with empty defaults instead of
* touching `window` or an undefined Vite base URL.
*/
describe("runner environment outside a browser", () => {
test("imports without a window and defaults every URL param", async () => {
const env = await import("./appEnv");
expect(env.GAME_ID).toBeNull();
expect(env.STATE_PARAM).toBeNull();
expect(env.MODE).toBe("play");
expect(env.resolvedRun()).toEqual([]);
});
});
5 changes: 3 additions & 2 deletions apps/dev/src/appEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import type { CaptureViewOverrides } from "./captureView";

// The site mounts this runner under /play/ — resolve games' root-absolute
// /models and /materials paths against wherever the app is actually served.
installAssetBase(import.meta.env.BASE_URL);
installAssetBase(import.meta.env?.BASE_URL ?? "/");

const urlParams = new URLSearchParams(window.location.search);
const urlParams = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search);

/** Explicit game only — bare `/` shows the picker so demo is never a silent surprise. */
export const GAME_ID = urlParams.get("game") ?? (import.meta.env.VITE_GAME_ID as string | undefined) ?? null;
Expand Down Expand Up @@ -79,3 +79,4 @@ export const CONVEX_URL = import.meta.env.VITE_CONVEX_URL as string | undefined;
export const P2P_ROLE = urlParams.get("p2p");

if (import.meta.env.DEV && GAME_ID !== null) installSaveEndpoint("/__jgengine/save", GAME_ID);

20 changes: 20 additions & 0 deletions apps/dev/src/demo/gameMenuDemo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";

import { gameMenuDemoGame } from "./gameMenuDemo";

describe("game menu demo", () => {
test("declares the capture states its views use", () => {
const states = gameMenuDemoGame.capture?.states ?? {};
const views = gameMenuDemoGame.capture?.views ?? {};
for (const view of Object.values(views)) {
if (view.state !== undefined) expect(Object.keys(states)).toContain(view.state);
}
expect(Object.keys(views)).toEqual(expect.arrayContaining(["title", "character", "saves", "pause"]));
});

test("the runner registry exposes the demo without Vite", async () => {
const { gameRegistry } = await import("../registries");
expect(typeof gameRegistry["game-menu"]).toBe("function");
});
});

Loading
Loading