@jgengine/react 0.8.0 → 0.9.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.
package/llms.txt CHANGED
@@ -1,7 +1,7 @@
1
1
  # @jgengine/react
2
2
  > React UI layer for JGengine: GameProvider, hooks, and headless primitives over @jgengine/core.
3
3
 
4
- Version: 0.8.0
4
+ Version: 0.9.0
5
5
  License: AGPL-3.0-only
6
6
  Repository: https://github.com/Noisemaker111/jgengine
7
7
  Docs: https://jgengine.com
@@ -11,305 +11,451 @@ Imports use deep paths: `@jgengine/react/<path>`.
11
11
 
12
12
  ### @jgengine/react/chat
13
13
 
14
- - chatTransportFromSync (function): function chatTransportFromSync(sync: ChatSync): ChatTransport — Lifts a callback-style ChatSync (e.g. createWsBackend().chatSyncFor(serverId)) into the hook-shaped ChatTransport contract. Create once per sync — outside render or inside useMemo — so subscriptions survive re-renders.
15
- - ChatLog (function): function ChatLog({ channelId, limit, className, messageClassName, renderMessage, }: { channelId: string; limit?: number; className?: string; messageClassName?: string; renderMessage?: (message: ChatMessage) => ReactNode; }): React.JSX.Element
16
- - ChatInput (function): function ChatInput({ channelId, className, inputClassName, buttonClassName, placeholder, sendLabel, onSent, onRejected, }: { channelId: string; className?: string; inputClassName?: string; buttonClassName?: string; placeholder?: string; sendLabel?: ReactNode; onSent?: (message: ChatMessage) => void;…
17
14
  - ChannelTabs (function): function ChannelTabs({ channels, active, onSelect, className, tabClassName, activeTabClassName, renderTab, }: { channels?: readonly string[]; active: string; onSelect: (channelId: string) => void; className?: string; tabClassName?: string; activeTabClassName?: string; renderTab?: (channelId: string,…
15
+ - ChatInput (function): function ChatInput({ channelId, className, inputClassName, buttonClassName, placeholder, sendLabel, onSent, onRejected, }: { channelId: string; className?: string; inputClassName?: string; buttonClassName?: string; placeholder?: string; sendLabel?: ReactNode; onSent?: (message: ChatMessage) => void;…
16
+ - ChatLog (function): function ChatLog({ channelId, limit, className, messageClassName, renderMessage, }: { channelId: string; limit?: number; className?: string; messageClassName?: string; renderMessage?: (message: ChatMessage) => ReactNode; }): React.JSX.Element
18
17
  - ChatPanel (function): function ChatPanel({ channels, initialChannel, limit, className, tabsClassName, tabClassName, activeTabClassName, logClassName, messageClassName, inputClassName, inputFieldClassName, sendButtonClassName, placeholder, renderMessage, onRejected, }: { channels?: readonly string[]; initialChannel?: stri…
18
+ - chatTransportFromSync (function): function chatTransportFromSync(sync: ChatSync): ChatTransport — Lifts a callback-style ChatSync (e.g. createWsBackend().chatSyncFor(serverId)) into the hook-shaped ChatTransport contract. Create once per sync — outside render or inside useMemo — so subscriptions survive re-renders.
19
+
20
+ ### @jgengine/react/chatBubbles
21
+
22
+ - ChatBubble (interface): interface ChatBubble
23
+ - ChatBubblesOptions (interface): interface ChatBubblesOptions
24
+ - latestChatBubbles (function): function latestChatBubbles(messages: readonly ChatMessage[], nowMs: number, ttlMs: number): ChatBubble[]
25
+ - useChatBubbles (function): function useChatBubbles(options?: ChatBubblesOptions): readonly ChatBubble[]
26
+ - useEntityChatBubble (function): function useEntityChatBubble(instanceId: string, options?: ChatBubblesOptions): ChatBubble | null
19
27
 
20
28
  ### @jgengine/react/components
21
29
 
22
- - SlotGrid (function): function SlotGrid({ inventoryId, className, renderSlot, }: { inventoryId: string; className?: string; renderSlot?: (slot: InventorySlot, index: number) => ReactNode; }): React.JSX.Element
23
- - HealthBar (function): function HealthBar({ instanceId, statId, className, fillClassName, }: { instanceId: string; statId: string; className?: string; fillClassName?: string; }): React.JSX.Element | null
24
- - CurrencyPill (function): function CurrencyPill({ currencyId, className }: { currencyId: string; className?: string }): React.JSX.Element
25
- - ProximityPrompt (function): function ProximityPrompt({ prompt, className, }: { prompt: ProximityPromptDef; className?: string; }): React.JSX.Element
26
- - Screen (function): function Screen({ id, open = true, className, children, }: { id: string; open?: boolean; className?: string; children?: ReactNode; }): React.JSX.Element | null
27
- - KeybindRow (function): function KeybindRow({ action, keys, className, }: { action: string; keys: readonly string[]; className?: string; }): React.JSX.Element
28
- - resolveDialogueInvoke (function): function resolveDialogueInvoke(choice: DialogueChoice, result: CheckResult | null): { command: string; args?: unknown } | null
29
- - DialogueBox (function): function DialogueBox({ dialogue, onChoice, rng, className, lineClassName, speakerClassName, choicesClassName, choiceClassName, checkClassName, }: { dialogue: DialogueDef; onChoice?: (choice: DialogueChoice, result: CheckResult | null) => void; rng?: () => number; className?: string; lineClassName?: …
30
- - SkillCheckBar (function): function SkillCheckBar({ config, startedAt, className, trackClassName, zoneClassName, markerClassName, renderStatus, }: { config: SkillCheckConfig; startedAt: number; className?: string; trackClassName?: string; zoneClassName?: string; markerClassName?: string; renderStatus?: (result: SkillCheckResu…
31
- - QteTrack (function): function QteTrack({ steps, startedAt, className, stepClassName, activeClassName, doneClassName, }: { steps: readonly QteStep[]; startedAt: number; className?: string; stepClassName?: string; activeClassName?: string; doneClassName?: string; }): React.JSX.Element
32
30
  - CaptureOdds (function): function CaptureOdds({ chance, className, fillClassName, }: { chance: number; className?: string; fillClassName?: string; }): React.JSX.Element
33
- - ToastStack (function): function ToastStack({ action, limit = 4, className, renderToast, }: { action: string; limit?: number; className?: string; renderToast?: (entry: FeedEntry, index: number) => ReactNode; }): React.JSX.Element | null
31
+ - CurrencyPill (function): function CurrencyPill({ currencyId, className }: { currencyId: string; className?: string }): React.JSX.Element
34
32
  - DeathScreen (function): function DeathScreen({ statId = "health", open, className, children, }: { statId?: string; open?: boolean; className?: string; children?: ReactNode; }): React.JSX.Element
35
- - LevelUpFlash (function): function LevelUpFlash({ stat, durationMs = 1600, className, children, renderFlash, }: { stat?: string; durationMs?: number; className?: string; children?: ReactNode; renderFlash?: (event: StatLevelUpEvent) => ReactNode; }): React.JSX.Element | null
33
+ - DialogueBox (function): function DialogueBox({ dialogue, onChoice, rng, className, lineClassName, speakerClassName, choicesClassName, choiceClassName, checkClassName, }: { dialogue: DialogueDef; onChoice?: (choice: DialogueChoice, result: CheckResult | null) => void; rng?: () => number; className?: string; lineClassName?:
36
34
  - DialogueCheck (interface): interface DialogueCheck
37
35
  - DialogueChoice (interface): interface DialogueChoice
38
- - DialogueLine (type): type DialogueLine = { speaker: string; text: string } | { choices: readonly DialogueChoice[] }
39
36
  - DialogueDef (interface): interface DialogueDef
37
+ - DialogueLine (type): type DialogueLine = { speaker: string; text: string } | { choices: readonly DialogueChoice[] }
38
+ - HealthBar (function): function HealthBar({ instanceId, statId, className, fillClassName, }: { instanceId: string; statId: string; className?: string; fillClassName?: string; }): React.JSX.Element | null
39
+ - KeybindRow (function): function KeybindRow({ action, keys, className, }: { action: string; keys: readonly string[]; className?: string; }): React.JSX.Element
40
+ - LevelUpFlash (function): function LevelUpFlash({ stat, durationMs = 1600, className, children, renderFlash, }: { stat?: string; durationMs?: number; className?: string; children?: ReactNode; renderFlash?: (event: StatLevelUpEvent) => ReactNode; }): React.JSX.Element | null
41
+ - ProximityPrompt (function): function ProximityPrompt({ prompt, className, }: { prompt: ProximityPromptDef; className?: string; }): React.JSX.Element
42
+ - QteTrack (function): function QteTrack({ steps, startedAt, className, stepClassName, activeClassName, doneClassName, }: { steps: readonly QteStep[]; startedAt: number; className?: string; stepClassName?: string; activeClassName?: string; doneClassName?: string; }): React.JSX.Element
43
+ - Screen (function): function Screen({ id, open = true, className, children, }: { id: string; open?: boolean; className?: string; children?: ReactNode; }): React.JSX.Element | null
44
+ - SkillCheckBar (function): function SkillCheckBar({ config, startedAt, className, trackClassName, zoneClassName, markerClassName, renderStatus, }: { config: SkillCheckConfig; startedAt: number; className?: string; trackClassName?: string; zoneClassName?: string; markerClassName?: string; renderStatus?: (result: SkillCheckResu…
45
+ - SlotGrid (function): function SlotGrid({ inventoryId, className, renderSlot, }: { inventoryId: string; className?: string; renderSlot?: (slot: InventorySlot, index: number) => ReactNode; }): React.JSX.Element
46
+ - ToastStack (function): function ToastStack({ action, limit = 4, className, renderToast, }: { action: string; limit?: number; className?: string; renderToast?: (entry: FeedEntry, index: number) => ReactNode; }): React.JSX.Element | null
47
+ - paintQteStepDom (function): function paintQteStepDom(elements: ReadonlyMap<string, HTMLElement>, steps: readonly QteStep[], elapsed: number, activeId: string | null, stepClassName?: string, activeClassName?: string, doneClassName?: string): void
48
+ - paintSkillCheckDom (function): function paintSkillCheckDom(root: HTMLElement, zone: HTMLElement, marker: HTMLElement, config: SkillCheckConfig, result: SkillCheckResult): void
49
+ - resolveDialogueInvoke (function): function resolveDialogueInvoke(choice: DialogueChoice, result: CheckResult | null): { command: string; args?: unknown } | null
40
50
 
41
51
  ### @jgengine/react/display
42
52
 
43
- - useDisplayProfile (function): function useDisplayProfile(): DisplayProfile
44
53
  - DisplayProfile (interface): interface DisplayProfile
54
+ - useDisplayProfile (function): function useDisplayProfile(): DisplayProfile
45
55
 
46
56
  ### @jgengine/react/dragLayer
47
57
 
48
- - useDragLayer (function): function useDragLayer<T>(options?: { onDrop?: (info: DropInfo<T>) => void; }): DragLayer<T>
49
58
  - DragGhost (function): function DragGhost<T>({ layer, className, style, children, }: { layer: DragLayer<T>; className?: string; style?: CSSProperties; children?: (payload: DragPayload<T>) => ReactNode; }): React.JSX.Element | null
50
- - DraggableCard (function): function DraggableCard<T>({ id, value, layer, className, children, onRotate, }: { id: string; value: T; layer: DragLayer<T>; className?: string; children?: ReactNode; onRotate?: boolean; }): React.JSX.Element
51
- - DropZone (function): function DropZone<T>({ id, layer, className, activeClassName, cellSize, children, }: { id: string; layer: DragLayer<T>; className?: string; activeClassName?: string; cellSize?: number; children?: ReactNode; }): React.JSX.Element
59
+ - DragLayer (interface): interface DragLayer<T>
52
60
  - DragPayload (interface): interface DragPayload<T>
53
61
  - DragState (interface): interface DragState<T>
62
+ - DraggableCard (function): function DraggableCard<T>({ id, value, layer, className, children, onRotate, }: { id: string; value: T; layer: DragLayer<T>; className?: string; children?: ReactNode; onRotate?: boolean; }): React.JSX.Element
54
63
  - DropInfo (interface): interface DropInfo<T>
55
- - DragLayer (interface): interface DragLayer<T>
64
+ - DropZone (function): function DropZone<T>({ id, layer, className, activeClassName, cellSize, children, }: { id: string; layer: DragLayer<T>; className?: string; activeClassName?: string; cellSize?: number; children?: ReactNode; }): React.JSX.Element
65
+ - useDragLayer (function): function useDragLayer<T>(options?: { onDrop?: (info: DropInfo<T>) => void; }): DragLayer<T>
56
66
 
57
67
  ### @jgengine/react/engineStore
58
68
 
59
- - useEngineState (function): function useEngineState<TState>(store: ReadableEngineStore<TState>): TState
60
- - useEngineStore (function): function useEngineStore<TState, TSelected>(store: ReadableEngineStore<TState>, selector: (state: TState) => TSelected): TSelected
61
- - useEngineEvent (function): function useEngineEvent<TEventMap extends object, K extends keyof TEventMap>(store: EventfulEngineStore<TEventMap>, eventName: K, handler: (payload: TEventMap[K]) => void): void
62
- - ReadableEngineStore (interface): interface ReadableEngineStore<TState>
63
69
  - EventfulEngineStore (interface): interface EventfulEngineStore<TEventMap extends object>
70
+ - ReadableEngineStore (interface): interface ReadableEngineStore<TState>
71
+ - useEngineEvent (function): function useEngineEvent<TEventMap extends object, K extends keyof TEventMap>(store: EventfulEngineStore<TEventMap>, eventName: K, handler: (payload: TEventMap[K]) => void): void
72
+ - useEngineState (function): function useEngineState<TState>(store: ReadableEngineStore<TState>): TState
73
+ - useEngineStore (function): function useEngineStore<TState, TSelected>(store: ReadableEngineStore<TState>, selector: (state: TState) => TSelected, isEqual: (previous: TSelected, next: TSelected) => boolean = Object.is): TSelected
74
+
75
+ ### @jgengine/react/fogOverlay
76
+
77
+ - FogCellRect (interface): interface FogCellRect
78
+ - FogPaintSurface (interface): interface FogPaintSurface
79
+ - createFogDataUrl (function): function createFogDataUrl(fog: FogCells, canvasSize: { width: number; height: number }, cellRect: (col: number, row: number) => FogCellRect | null, fill = "rgba(11, 15, 20, 0.82)"): string | null
80
+ - forEachUnrevealedFogCell (function): function forEachUnrevealedFogCell(fog: FogCells, visit: (col: number, row: number) => void): number
81
+ - paintFogOverlay (function): function paintFogOverlay(surface: FogPaintSurface, canvasSize: { width: number; height: number }, fog: FogCells, cellRect: (col: number, row: number) => FogCellRect | null, fill = "rgba(11, 15, 20, 0.82)"): number
64
82
 
65
83
  ### @jgengine/react/gameIcons
66
84
 
67
- - isGameIconName (function): function isGameIconName(value: string): value is GameIconName
68
- - GameIcon (function): function GameIcon({ name, size = 24, color, className, }: { name: GameIconName; size?: number; color?: string; className?: string; }): React.JSX.Element
69
- - iconForItemId (function): function iconForItemId(itemId: string): GameIconName | null
70
- - iconForAction (function): function iconForAction(action: string): GameIconName | null — Control glyph for a semantic action name (`hardDrop`, `sprint`, `shiftLeft`), or null when no rule matches.
71
85
  - GAME_ICON_NAMES (const): const GAME_ICON_NAMES: readonly ["sword", "dagger", "axe", "hammer", "bow", "arrow", "staff", "wand", "spear", "crossbow", "gun", "bomb", "shield", "helmet", "chestplate", "boots", "gauntlet", "ring", "amulet", "cloak", "backpack", "torch", "potionRed", "potionBlue", "scroll", "tome", "meat", "bread…
86
+ - GameIcon (function): function GameIcon({ name, size = 24, color, className, }: { name: GameIconName; size?: number; color?: string; className?: string; }): React.JSX.Element
72
87
  - GameIconName (type): type GameIconName = (typeof GAME_ICON_NAMES)[number]
88
+ - iconForAction (function): function iconForAction(action: string): GameIconName | null — Control glyph for a semantic action name (`hardDrop`, `sprint`, `shiftLeft`), or null when no rule matches.
89
+ - iconForItemId (function): function iconForItemId(itemId: string): GameIconName | null
90
+ - isGameIconName (function): function isGameIconName(value: string): value is GameIconName
73
91
 
74
92
  ### @jgengine/react/hooks
75
93
 
76
- - useGameStore (function): function useGameStore<T>(selector: (ctx: GameContext) => T): T
77
- - useGame (function): function useGame(): { commands: GameContext["game"]["commands"]; events: GameEvents }
78
- - usePlayer (function): function usePlayer(): { userId: string; isNew: boolean }
79
- - useSceneEntities (function): function useSceneEntities(): readonly SceneEntity[]
80
- - useSceneObjects (function): function useSceneObjects(): readonly SceneObject[]
81
- - useWorldItems (function): function useWorldItems(): readonly WorldItemRecord[]
82
- - useNearestWorldItem (function): function useNearestWorldItem(radius: number): WorldItemRecord | null — Nearest ground item within `radius` of the local player — drives a pickup prompt/highlight.
83
- - useEntityStat (function): function useEntityStat(instanceId: string, statId: string): StatValue | null
84
- - useTarget (function): function useTarget(fromInstanceId: string): string | null
85
- - useInventory (function): function useInventory(inventoryId: string): readonly InventorySlot[]
94
+ - AbilitySlotBindingOptions (interface): interface AbilitySlotBindingOptions
95
+ - EventMeterView (interface): interface EventMeterView
96
+ - UseAxisChannelResult (interface): interface UseAxisChannelResult
97
+ - WorldBrowserState (interface): interface WorldBrowserState
98
+ - abilityKitNeedsHeartbeat (function): function abilityKitNeedsHeartbeat(kit: AbilityKit, resourceAvailable?: number): boolean
99
+ - createHeldKeyTracker (function): function createHeldKeyTracker(target: HeldKeyEventTarget): { isDown: (code: string) => boolean; dispose: () => void; }
100
+ - eventMeterNeedsHeartbeat (function): function eventMeterNeedsHeartbeat(meter: EventMeter, previous: EventMeterView | null): boolean
101
+ - localPlayerEntity (function): function localPlayerEntity(ctx: GameContext): SceneEntity | null
102
+ - useAbilitySlot (function): function useAbilitySlot(kit: AbilityKit, slotId: string, resourceAvailable?: number, options?: AbilitySlotBindingOptions): AbilitySlotSnapshot | null
103
+ - useAbilitySlots (function): function useAbilitySlots(kit: AbilityKit, resourceAvailable?: number, options?: AbilitySlotBindingOptions): AbilitySlotSnapshot[]
104
+ - useActivePrompt (function): function useActivePrompt<T extends PositionedPrompt>(prompts?: readonly T[]): T | null
105
+ - useAxisChannel (function): function useAxisChannel(config: AxisChannelConfig): UseAxisChannelResult — Wires useHeldKeys into a fresh AxisChannel, ready for a per-frame `channel.sample(dt, isDown)`. The channel is recreated when `config` identity changes, so pass a stable config (useMemo/module constant at the call site) unless a rebind is intended.
106
+ - useChat (function): function useChat(channelId: string, options?: { limit?: number }): ChatMessage[]
86
107
  - useCurrency (function): function useCurrency(currencyId: string): number
108
+ - useEntityStat (function): function useEntityStat(instanceId: string, statId: string): StatValue | null
109
+ - useEventMeter (function): function useEventMeter(meter: EventMeter, options?: AbilitySlotBindingOptions): EventMeterView
87
110
  - useFeed (function): function useFeed({ action, limit }: { action: string; limit?: number }): FeedEntry[]
88
- - useQuestJournal (function): function useQuestJournal(): QuestInstance[]
111
+ - useFriendRequests (function): function useFriendRequests(): FriendRequestEntry[]
89
112
  - useFriends (function): function useFriends(): FriendEntry[]
113
+ - useGame (function): function useGame(): { commands: GameContext["game"]["commands"]; events: GameEvents }
114
+ - useGameClock (function): function useGameClock(): ClockSnapshot & { controls: SimClock }
115
+ - useGamePhase (function): function useGamePhase(): { phase: GamePhase; setPhase: (phase: GamePhase) => void } — Live run phase + a setter that also gates the shell's touch controls. `menu`/`paused`/`ended` hide the touch dock; `playing` shows it.
116
+ - useGameStore (function): function useGameStore<T>(selector: (ctx: GameContext) => T, isEqual: (previous: T, next: T) => boolean = Object.is): T
117
+ - useHeldKeys (function): function useHeldKeys(): (code: string) => boolean — Held-key predicate backed by window keydown/keyup/blur listeners (blur clears held state so a released-off-window key doesn't stick). SSR-safe: listeners attach in an effect, never at module scope. The returned predicate is stable across renders.
118
+ - useInventory (function): function useInventory(inventoryId: string): readonly InventorySlot[]
119
+ - useLeaderboard (function): function useLeaderboard(stat: string, options: { scope: LeaderboardScope; limit?: number }): { userId: string; value: number }[]
120
+ - useLocalPlayerDead (function): function useLocalPlayerDead(healthStatId = "health"): boolean
121
+ - useNearestWorldItem (function): function useNearestWorldItem(radius: number): WorldItemRecord | null — Nearest ground item within `radius` of the local player — drives a pickup prompt/highlight.
90
122
  - useParty (function): function useParty(): PartyMemberEntry[]
91
- - usePresence (function): function usePresence(userId: string): PresenceInfo
92
- - useWorldInvites (function): function useWorldInvites(): WorldInvite[]
93
- - useChat (function): function useChat(channelId: string, options?: { limit?: number }): ChatMessage[]
94
- - useFriendRequests (function): function useFriendRequests(): FriendRequestEntry[]
95
123
  - usePartyInvites (function): function usePartyInvites(): PartyInviteEntry[]
96
- - useWorldBrowser (function): function useWorldBrowser(options: { fetchSessions: () => Promise<readonly SessionListing[]>; filter?: MatchFilter; limit?: number; refreshMs?: number; }): WorldBrowserState — Polls a host-supplied session fetcher (e.g. createWsBackend().browse) and filters through matchmaking's browseSessions. fetchSessions must be identity-stable (wrap in useCallback at the call site) or every render refetches.
124
+ - usePlayer (function): function usePlayer(): { userId: string; isNew: boolean }
125
+ - usePresence (function): function usePresence(userId: string): PresenceInfo
126
+ - useQuestJournal (function): function useQuestJournal(): QuestInstance[]
97
127
  - useRoster (function): function useRoster(userId?: string): readonly RosterEntry[]
98
- - useLeaderboard (function): function useLeaderboard(stat: string, options: { scope: LeaderboardScope; limit?: number }): { userId: string; value: number }[]
99
- - useLocalPlayerDead (function): function useLocalPlayerDead(healthStatId = "health"): boolean
100
- - localPlayerEntity (function): function localPlayerEntity(ctx: GameContext): SceneEntity | null
101
- - useGameClock (function): function useGameClock(): ClockSnapshot & { controls: SimClock }
102
- - useActivePrompt (function): function useActivePrompt<T extends PositionedPrompt>(prompts?: readonly T[]): T | null
103
- - useAbilitySlots (function): function useAbilitySlots(kit: AbilityKit, resourceAvailable?: number, options?: AbilitySlotBindingOptions): AbilitySlotSnapshot[]
104
- - useAbilitySlot (function): function useAbilitySlot(kit: AbilityKit, slotId: string, resourceAvailable?: number, options?: AbilitySlotBindingOptions): AbilitySlotSnapshot | null
105
- - useEventMeter (function): function useEventMeter(meter: EventMeter, options?: AbilitySlotBindingOptions): EventMeterView
106
- - createHeldKeyTracker (function): function createHeldKeyTracker(target: HeldKeyEventTarget): { isDown: (code: string) => boolean; dispose: () => void; }
107
- - useHeldKeys (function): function useHeldKeys(): (code: string) => boolean Held-key predicate backed by window keydown/keyup/blur listeners (blur clears held state so a released-off-window key doesn't stick). SSR-safe: listeners attach in an effect, never at module scope. The returned predicate is stable across renders.
108
- - useAxisChannel (function): function useAxisChannel(config: AxisChannelConfig): UseAxisChannelResult Wires useHeldKeys into a fresh AxisChannel, ready for a per-frame `channel.sample(dt, isDown)`. The channel is recreated when `config` identity changes, so pass a stable config (useMemo/module constant at the call site) unless a rebind is intended.
109
- - WorldBrowserState (interface): interface WorldBrowserState
110
- - AbilitySlotBindingOptions (interface): interface AbilitySlotBindingOptions
111
- - EventMeterView (interface): interface EventMeterView
112
- - UseAxisChannelResult (interface): interface UseAxisChannelResult
128
+ - useSceneEntities (function): function useSceneEntities(): readonly SceneEntity[]
129
+ - useSceneObjects (function): function useSceneObjects(): readonly SceneObject[]
130
+ - useTarget (function): function useTarget(fromInstanceId: string): string | null
131
+ - useWorldBrowser (function): function useWorldBrowser(options: { fetchSessions: () => Promise<readonly SessionListing[]>; filter?: MatchFilter; limit?: number; refreshMs?: number; }): WorldBrowserState — Polls a host-supplied session fetcher (e.g. createWsBackend().browse) and filters through matchmaking's browseSessions. fetchSessions must be identity-stable (wrap in useCallback at the call site) or every render refetches.
132
+ - useWorldInvites (function): function useWorldInvites(): WorldInvite[]
133
+ - useWorldItems (function): function useWorldItems(): readonly WorldItemRecord[]
134
+
135
+ ### @jgengine/react/hudLayout
136
+
137
+ - HudCanvas (function): function HudCanvas({ layout, editChord, compactScale, className, style, children, }: { layout: HudLayoutStore; editChord?: HudEditChord | false; /** Zoom applied to the whole HUD on compact displays. Default 0.85. */ compactScale?: number; className?: string; style?: CSSProperties; children?: ReactN… — Full-viewport HUD surface. Panels declared with `HudPanel` flow into nine anchor regions and stack automatically with a gap — no per-panel pixel offsets, no manual clearance for sibling panels, the touch-control dock (`--jg-hud-dock-clearance`), or device safe areas. On compact displays the whole surface scales down and each panel applies its `compact` behavior.
138
+ - HudCompactMode (type): type HudCompactMode = "keep" | "chip" | "hide" — How a panel behaves on compact (phone-scale) displays. `keep` stays visible at the global compact scale, `chip` collapses to a small tap-to-expand pill, `hide` unmounts entirely.
139
+ - HudEditChord (interface): interface HudEditChord
140
+ - HudPanel (function): function HudPanel({ id, anchor = "top-left", order, compact: compactMode = "keep", chip, interactive, inset, locked, className, style, children, }: { id: string; anchor?: HudAnchor; /** Stack position within the region, ascending outward from the screen edge. Default 0. */ order?: number; /** Behavi… — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
141
+ - useHudLayout (function): function useHudLayout(options?: { storageKey?: string; snap?: number; locked?: boolean; }): HudLayoutStore
142
+
143
+ ### @jgengine/react/hudViewport
144
+
145
+ - HudViewportContextValue (interface): interface HudViewportContextValue
146
+ - HudViewportProvider (function): function HudViewportProvider({ platforms, config, userScale, children, }: { platforms: readonly HudPlatform[] | undefined; config: HudViewportConfig | undefined; userScale?: number; children?: ReactNode; }): React.JSX.Element — Mounted by the shell around `GameUI` so every `HudCanvas` inside the game picks up the game's `platforms`/`hudFit` declaration and the player's UI scale setting without any game-side wiring.
147
+ - useHudViewport (function): function useHudViewport(): HudViewportContextValue | null
113
148
 
114
149
  ### @jgengine/react/identity
115
150
 
116
- - clerkIdentity (function): function clerkIdentity(state: ClerkUserState, options?: { signOut?: () => void }): IdentitySource
117
- - betterAuthIdentity (function): function betterAuthIdentity(state: BetterAuthSessionState, options?: { signOut?: () => void }): IdentitySource
118
- - guestIdentity (function): function guestIdentity(seed?: string): IdentitySource
151
+ - BetterAuthSessionState (interface): interface BetterAuthSessionState
152
+ - BetterAuthUserShape (interface): interface BetterAuthUserShape
153
+ - ClerkUserShape (interface): interface ClerkUserShape
154
+ - ClerkUserState (interface): interface ClerkUserState
119
155
  - GameIdentityProvider (function): function GameIdentityProvider({ source, children, }: { source: IdentitySource; children?: ReactNode; }): React.JSX.Element
120
- - useSession (function): function useSession(): IdentitySource
121
- - useAuthedPlayer (function): function useAuthedPlayer(options?: { guestSeed?: string }): PlayerIdentity | null
156
+ - IdentitySource (interface): interface IdentitySource
122
157
  - RequireSession (function): function RequireSession({ fallback, loading, children, }: { fallback?: ReactNode; loading?: ReactNode; children?: ReactNode; }): React.JSX.Element
123
- - UserBadge (function): function UserBadge({ className, avatarClassName, nameClassName, renderBadge, }: { className?: string; avatarClassName?: string; nameClassName?: string; renderBadge?: (session: AuthSession) => ReactNode; }): React.JSX.Element | null
124
158
  - SignOutButton (function): function SignOutButton({ className, children, }: { className?: string; children?: ReactNode; }): React.JSX.Element | null
125
- - IdentitySource (interface): interface IdentitySource
126
- - ClerkUserShape (interface): interface ClerkUserShape
127
- - ClerkUserState (interface): interface ClerkUserState
128
- - BetterAuthUserShape (interface): interface BetterAuthUserShape
129
- - BetterAuthSessionState (interface): interface BetterAuthSessionState
130
-
131
- ### @jgengine/react/index
132
-
133
- - GameProvider (function): function GameProvider({ context, children }: { context: GameContext; children?: ReactNode }): React.JSX.Element
134
- - useGameContext (function): function useGameContext(): GameContext
135
- - clerkIdentity (function): function clerkIdentity(state: ClerkUserState, options?: { signOut?: () => void }): IdentitySource
159
+ - UserBadge (function): function UserBadge({ className, avatarClassName, nameClassName, renderBadge, }: { className?: string; avatarClassName?: string; nameClassName?: string; renderBadge?: (session: AuthSession) => ReactNode; }): React.JSX.Element | null
136
160
  - betterAuthIdentity (function): function betterAuthIdentity(state: BetterAuthSessionState, options?: { signOut?: () => void }): IdentitySource
161
+ - clerkIdentity (function): function clerkIdentity(state: ClerkUserState, options?: { signOut?: () => void }): IdentitySource
137
162
  - guestIdentity (function): function guestIdentity(seed?: string): IdentitySource
138
- - GameIdentityProvider (function): function GameIdentityProvider({ source, children, }: { source: IdentitySource; children?: ReactNode; }): React.JSX.Element
139
- - useSession (function): function useSession(): IdentitySource
140
163
  - useAuthedPlayer (function): function useAuthedPlayer(options?: { guestSeed?: string }): PlayerIdentity | null
141
- - RequireSession (function): function RequireSession({ fallback, loading, children, }: { fallback?: ReactNode; loading?: ReactNode; children?: ReactNode; }): React.JSX.Element
142
- - UserBadge (function): function UserBadge({ className, avatarClassName, nameClassName, renderBadge, }: { className?: string; avatarClassName?: string; nameClassName?: string; renderBadge?: (session: AuthSession) => ReactNode; }): React.JSX.Element | null
143
- - SignOutButton (function): function SignOutButton({ className, children, }: { className?: string; children?: ReactNode; }): React.JSX.Element | null
144
- - IdentitySource (interface): interface IdentitySource
145
- - ClerkUserShape (interface): interface ClerkUserShape
146
- - ClerkUserState (interface): interface ClerkUserState
147
- - BetterAuthUserShape (interface): interface BetterAuthUserShape
164
+ - useSession (function): function useSession(): IdentitySource
165
+
166
+ ### @jgengine/react/index
167
+
168
+ - AbilitySlotBindingOptions (interface): interface AbilitySlotBindingOptions
169
+ - AddFriendButton (function): function AddFriendButton({ toUserId, className, children, onRequested, onRejected, }: { toUserId: string; className?: string; children?: ReactNode; onRequested?: (requestId: string) => void; onRejected?: (reason: string) => void; }): React.JSX.Element
148
170
  - BetterAuthSessionState (interface): interface BetterAuthSessionState
149
- - chatTransportFromSync (function): function chatTransportFromSync(sync: ChatSync): ChatTransport — Lifts a callback-style ChatSync (e.g. createWsBackend().chatSyncFor(serverId)) into the hook-shaped ChatTransport contract. Create once per sync — outside render or inside useMemo — so subscriptions survive re-renders.
150
- - ChatLog (function): function ChatLog({ channelId, limit, className, messageClassName, renderMessage, }: { channelId: string; limit?: number; className?: string; messageClassName?: string; renderMessage?: (message: ChatMessage) => ReactNode; }): React.JSX.Element
151
- - ChatInput (function): function ChatInput({ channelId, className, inputClassName, buttonClassName, placeholder, sendLabel, onSent, onRejected, }: { channelId: string; className?: string; inputClassName?: string; buttonClassName?: string; placeholder?: string; sendLabel?: ReactNode; onSent?: (message: ChatMessage) => void;…
171
+ - BetterAuthUserShape (interface): interface BetterAuthUserShape
172
+ - CaptureOdds (function): function CaptureOdds({ chance, className, fillClassName, }: { chance: number; className?: string; fillClassName?: string; }): React.JSX.Element
152
173
  - ChannelTabs (function): function ChannelTabs({ channels, active, onSelect, className, tabClassName, activeTabClassName, renderTab, }: { channels?: readonly string[]; active: string; onSelect: (channelId: string) => void; className?: string; tabClassName?: string; activeTabClassName?: string; renderTab?: (channelId: string,…
174
+ - ChatBubble (interface): interface ChatBubble
175
+ - ChatBubblesOptions (interface): interface ChatBubblesOptions
176
+ - ChatInput (function): function ChatInput({ channelId, className, inputClassName, buttonClassName, placeholder, sendLabel, onSent, onRejected, }: { channelId: string; className?: string; inputClassName?: string; buttonClassName?: string; placeholder?: string; sendLabel?: ReactNode; onSent?: (message: ChatMessage) => void;…
177
+ - ChatLog (function): function ChatLog({ channelId, limit, className, messageClassName, renderMessage, }: { channelId: string; limit?: number; className?: string; messageClassName?: string; renderMessage?: (message: ChatMessage) => ReactNode; }): React.JSX.Element
153
178
  - ChatPanel (function): function ChatPanel({ channels, initialChannel, limit, className, tabsClassName, tabClassName, activeTabClassName, logClassName, messageClassName, inputClassName, inputFieldClassName, sendButtonClassName, placeholder, renderMessage, onRejected, }: { channels?: readonly string[]; initialChannel?: stri…
154
- - useVoice (function): function useVoice(options?: UseVoiceOptions): VoiceState — Mic capture + push-to-talk + channel roster over the VoiceTransport signaling seam. Transmission gates the captured tracks' `enabled` flag; the media plane that actually moves audio bytes (WebRTC/SFU) stays behind the transport, host-supplied. Call once per voice channel and hand the returned state to the voice components.
155
- - PushToTalkButton (function): function PushToTalkButton({ voice, className, children, }: { voice: VoiceState; className?: string; children?: ReactNode; }): React.JSX.Element
156
- - MicToggle (function): function MicToggle({ voice, className, mutedLabel, unmutedLabel, }: { voice: VoiceState; className?: string; mutedLabel?: ReactNode; unmutedLabel?: ReactNode; }): React.JSX.Element
157
- - SpeakingIndicator (function): function SpeakingIndicator({ voice, userId, className, threshold = 0.01, children, }: { voice: VoiceState; userId: string; className?: string; threshold?: number; children?: ReactNode; }): React.JSX.Element
158
- - VoiceRoster (function): function VoiceRoster({ voice, className, participantClassName, renderParticipant, }: { voice: VoiceState; className?: string; participantClassName?: string; renderParticipant?: (participant: VoiceParticipant, gain: number) => ReactNode; }): React.JSX.Element
159
- - UseVoiceOptions (interface): interface UseVoiceOptions
160
- - VoiceState (interface): interface VoiceState
161
- - PresenceDot (function): function PresenceDot({ userId, className }: { userId: string; className?: string }): React.JSX.Element
162
- - FriendRow (function): function FriendRow({ friend, className, dotClassName, children, }: { friend: FriendEntry; className?: string; dotClassName?: string; children?: ReactNode; }): React.JSX.Element
163
- - FriendsList (function): function FriendsList({ className, rowClassName, dotClassName, emptyState, renderFriend, }: { className?: string; rowClassName?: string; dotClassName?: string; emptyState?: ReactNode; renderFriend?: (friend: FriendEntry) => ReactNode; }): React.JSX.Element
164
- - AddFriendButton (function): function AddFriendButton({ toUserId, className, children, onRequested, onRejected, }: { toUserId: string; className?: string; children?: ReactNode; onRequested?: (requestId: string) => void; onRejected?: (reason: string) => void; }): React.JSX.Element
165
- - FriendRequestsList (function): function FriendRequestsList({ className, rowClassName, acceptClassName, declineClassName, emptyState, renderRequest, }: { className?: string; rowClassName?: string; acceptClassName?: string; declineClassName?: string; emptyState?: ReactNode; renderRequest?: (request: FriendRequestEntry) => ReactNode…
166
- - PartyMemberRow (function): function PartyMemberRow({ member, className, dotClassName, children, }: { member: PartyMemberEntry; className?: string; dotClassName?: string; children?: ReactNode; }): React.JSX.Element
167
- - PartyFrame (function): function PartyFrame({ className, rowClassName, dotClassName, emptyState, renderMember, }: { className?: string; rowClassName?: string; dotClassName?: string; emptyState?: ReactNode; renderMember?: (member: PartyMemberEntry) => ReactNode; }): React.JSX.Element
168
- - PartyInviteToast (function): function PartyInviteToast({ className, acceptClassName, declineClassName, renderInvite, }: { className?: string; acceptClassName?: string; declineClassName?: string; renderInvite?: (invite: PartyInviteEntry) => ReactNode; }): React.JSX.Element | null
169
- - LeavePartyButton (function): function LeavePartyButton({ className, children, }: { className?: string; children?: ReactNode; }): React.JSX.Element | null
170
- - WorldInviteToast (function): function WorldInviteToast({ className, acceptClassName, declineClassName, onAccepted, renderInvite, }: { className?: string; acceptClassName?: string; declineClassName?: string; onAccepted: (target: WorldInviteTarget) => void; renderInvite?: (invite: WorldInvite) => ReactNode; }): React.JSX.Element …
171
- - InviteToWorldButton (function): function InviteToWorldButton({ toUserId, target, className, children, onInvited, onRejected, }: { toUserId: string; target: WorldInviteTarget; className?: string; children?: ReactNode; onInvited?: (inviteId: string) => void; onRejected?: (reason: string) => void; }): React.JSX.Element
172
- - WorldBrowser (function): function WorldBrowser({ listings, onJoin, className, rowClassName, joinClassName, emptyState, renderListing, }: { listings: readonly SessionListing[]; onJoin: (listing: SessionListing) => void; className?: string; rowClassName?: string; joinClassName?: string; emptyState?: ReactNode; renderListing?:…
173
- - JoinByCode (function): function JoinByCode({ onJoin, className, inputClassName, buttonClassName, placeholder, children, }: { onJoin: (code: string) => void; className?: string; inputClassName?: string; buttonClassName?: string; placeholder?: string; children?: ReactNode; }): React.JSX.Element
174
- - QuickMatchButton (function): function QuickMatchButton({ listings, onJoin, onNoMatch, filter, className, children, }: { listings: readonly SessionListing[]; onJoin: (listing: SessionListing) => void; onNoMatch?: () => void; filter?: MatchFilter; className?: string; children?: ReactNode; }): React.JSX.Element
175
- - EmoteWheel (function): function EmoteWheel({ emotes, radius, open = true, className, emoteClassName, onPlayed, onRejected, renderEmote, }: { emotes: readonly string[]; radius?: number; open?: boolean; className?: string; emoteClassName?: string; onPlayed?: (emoteId: string) => void; onRejected?: (reason: string) => void; …
176
- - useGameStore (function): function useGameStore<T>(selector: (ctx: GameContext) => T): T
177
- - useGame (function): function useGame(): { commands: GameContext["game"]["commands"]; events: GameEvents }
178
- - usePlayer (function): function usePlayer(): { userId: string; isNew: boolean }
179
- - useSceneEntities (function): function useSceneEntities(): readonly SceneEntity[]
180
- - useSceneObjects (function): function useSceneObjects(): readonly SceneObject[]
181
- - useWorldItems (function): function useWorldItems(): readonly WorldItemRecord[]
182
- - useNearestWorldItem (function): function useNearestWorldItem(radius: number): WorldItemRecord | null — Nearest ground item within `radius` of the local player — drives a pickup prompt/highlight.
183
- - useEntityStat (function): function useEntityStat(instanceId: string, statId: string): StatValue | null
184
- - useTarget (function): function useTarget(fromInstanceId: string): string | null
185
- - useInventory (function): function useInventory(inventoryId: string): readonly InventorySlot[]
186
- - useCurrency (function): function useCurrency(currencyId: string): number
187
- - useFeed (function): function useFeed({ action, limit }: { action: string; limit?: number }): FeedEntry[]
188
- - useQuestJournal (function): function useQuestJournal(): QuestInstance[]
189
- - useFriends (function): function useFriends(): FriendEntry[]
190
- - useParty (function): function useParty(): PartyMemberEntry[]
191
- - usePresence (function): function usePresence(userId: string): PresenceInfo
192
- - useWorldInvites (function): function useWorldInvites(): WorldInvite[]
193
- - useChat (function): function useChat(channelId: string, options?: { limit?: number }): ChatMessage[]
194
- - useFriendRequests (function): function useFriendRequests(): FriendRequestEntry[]
195
- - usePartyInvites (function): function usePartyInvites(): PartyInviteEntry[]
196
- - useWorldBrowser (function): function useWorldBrowser(options: { fetchSessions: () => Promise<readonly SessionListing[]>; filter?: MatchFilter; limit?: number; refreshMs?: number; }): WorldBrowserState — Polls a host-supplied session fetcher (e.g. createWsBackend().browse) and filters through matchmaking's browseSessions. fetchSessions must be identity-stable (wrap in useCallback at the call site) or every render refetches.
197
- - useRoster (function): function useRoster(userId?: string): readonly RosterEntry[]
198
- - useLeaderboard (function): function useLeaderboard(stat: string, options: { scope: LeaderboardScope; limit?: number }): { userId: string; value: number }[]
199
- - useLocalPlayerDead (function): function useLocalPlayerDead(healthStatId = "health"): boolean
200
- - localPlayerEntity (function): function localPlayerEntity(ctx: GameContext): SceneEntity | null
201
- - useGameClock (function): function useGameClock(): ClockSnapshot & { controls: SimClock }
202
- - useActivePrompt (function): function useActivePrompt<T extends PositionedPrompt>(prompts?: readonly T[]): T | null
203
- - useAbilitySlots (function): function useAbilitySlots(kit: AbilityKit, resourceAvailable?: number, options?: AbilitySlotBindingOptions): AbilitySlotSnapshot[]
204
- - useAbilitySlot (function): function useAbilitySlot(kit: AbilityKit, slotId: string, resourceAvailable?: number, options?: AbilitySlotBindingOptions): AbilitySlotSnapshot | null
205
- - useEventMeter (function): function useEventMeter(meter: EventMeter, options?: AbilitySlotBindingOptions): EventMeterView
206
- - createHeldKeyTracker (function): function createHeldKeyTracker(target: HeldKeyEventTarget): { isDown: (code: string) => boolean; dispose: () => void; }
207
- - useHeldKeys (function): function useHeldKeys(): (code: string) => boolean — Held-key predicate backed by window keydown/keyup/blur listeners (blur clears held state so a released-off-window key doesn't stick). SSR-safe: listeners attach in an effect, never at module scope. The returned predicate is stable across renders.
208
- - useAxisChannel (function): function useAxisChannel(config: AxisChannelConfig): UseAxisChannelResult — Wires useHeldKeys into a fresh AxisChannel, ready for a per-frame `channel.sample(dt, isDown)`. The channel is recreated when `config` identity changes, so pass a stable config (useMemo/module constant at the call site) unless a rebind is intended.
209
- - WorldBrowserState (interface): interface WorldBrowserState
210
- - AbilitySlotBindingOptions (interface): interface AbilitySlotBindingOptions
211
- - EventMeterView (interface): interface EventMeterView
212
- - UseAxisChannelResult (interface): interface UseAxisChannelResult
213
- - SlotGrid (function): function SlotGrid({ inventoryId, className, renderSlot, }: { inventoryId: string; className?: string; renderSlot?: (slot: InventorySlot, index: number) => ReactNode; }): React.JSX.Element
214
- - HealthBar (function): function HealthBar({ instanceId, statId, className, fillClassName, }: { instanceId: string; statId: string; className?: string; fillClassName?: string; }): React.JSX.Element | null
179
+ - ClerkUserShape (interface): interface ClerkUserShape
180
+ - ClerkUserState (interface): interface ClerkUserState
181
+ - Compass (function): function Compass({ facingYaw, center, markers, width = 340, fov = (Math.PI * 2) / 3, kindStyles = DEFAULT_MARKER_KINDS, className, }: CompassProps): ReactNode — Horizontal compass strip centered on the player's facing direction, with the eight cardinals and optional marker pips (bearing to each `MarkerSet` entry).
182
+ - CompassProps (interface): interface CompassProps
215
183
  - CurrencyPill (function): function CurrencyPill({ currencyId, className }: { currencyId: string; className?: string }): React.JSX.Element
216
- - ProximityPrompt (function): function ProximityPrompt({ prompt, className, }: { prompt: ProximityPromptDef; className?: string; }): React.JSX.Element
217
- - Screen (function): function Screen({ id, open = true, className, children, }: { id: string; open?: boolean; className?: string; children?: ReactNode; }): React.JSX.Element | null
218
- - KeybindRow (function): function KeybindRow({ action, keys, className, }: { action: string; keys: readonly string[]; className?: string; }): React.JSX.Element
219
- - resolveDialogueInvoke (function): function resolveDialogueInvoke(choice: DialogueChoice, result: CheckResult | null): { command: string; args?: unknown } | null
220
- - DialogueBox (function): function DialogueBox({ dialogue, onChoice, rng, className, lineClassName, speakerClassName, choicesClassName, choiceClassName, checkClassName, }: { dialogue: DialogueDef; onChoice?: (choice: DialogueChoice, result: CheckResult | null) => void; rng?: () => number; className?: string; lineClassName?: …
221
- - SkillCheckBar (function): function SkillCheckBar({ config, startedAt, className, trackClassName, zoneClassName, markerClassName, renderStatus, }: { config: SkillCheckConfig; startedAt: number; className?: string; trackClassName?: string; zoneClassName?: string; markerClassName?: string; renderStatus?: (result: SkillCheckResu…
222
- - QteTrack (function): function QteTrack({ steps, startedAt, className, stepClassName, activeClassName, doneClassName, }: { steps: readonly QteStep[]; startedAt: number; className?: string; stepClassName?: string; activeClassName?: string; doneClassName?: string; }): React.JSX.Element
223
- - CaptureOdds (function): function CaptureOdds({ chance, className, fillClassName, }: { chance: number; className?: string; fillClassName?: string; }): React.JSX.Element
224
- - ToastStack (function): function ToastStack({ action, limit = 4, className, renderToast, }: { action: string; limit?: number; className?: string; renderToast?: (entry: FeedEntry, index: number) => ReactNode; }): React.JSX.Element | null
225
184
  - DeathScreen (function): function DeathScreen({ statId = "health", open, className, children, }: { statId?: string; open?: boolean; className?: string; children?: ReactNode; }): React.JSX.Element
226
- - LevelUpFlash (function): function LevelUpFlash({ stat, durationMs = 1600, className, children, renderFlash, }: { stat?: string; durationMs?: number; className?: string; children?: ReactNode; renderFlash?: (event: StatLevelUpEvent) => ReactNode; }): React.JSX.Element | null
185
+ - DialogueBox (function): function DialogueBox({ dialogue, onChoice, rng, className, lineClassName, speakerClassName, choicesClassName, choiceClassName, checkClassName, }: { dialogue: DialogueDef; onChoice?: (choice: DialogueChoice, result: CheckResult | null) => void; rng?: () => number; className?: string; lineClassName?:
227
186
  - DialogueCheck (interface): interface DialogueCheck
228
187
  - DialogueChoice (interface): interface DialogueChoice
229
- - DialogueLine (type): type DialogueLine = { speaker: string; text: string } | { choices: readonly DialogueChoice[] }
230
188
  - DialogueDef (interface): interface DialogueDef
231
- - useEngineState (function): function useEngineState<TState>(store: ReadableEngineStore<TState>): TState
232
- - useEngineStore (function): function useEngineStore<TState, TSelected>(store: ReadableEngineStore<TState>, selector: (state: TState) => TSelected): TSelected
233
- - useEngineEvent (function): function useEngineEvent<TEventMap extends object, K extends keyof TEventMap>(store: EventfulEngineStore<TEventMap>, eventName: K, handler: (payload: TEventMap[K]) => void): void
234
- - ReadableEngineStore (interface): interface ReadableEngineStore<TState>
235
- - EventfulEngineStore (interface): interface EventfulEngineStore<TEventMap extends object>
236
- - useDragLayer (function): function useDragLayer<T>(options?: { onDrop?: (info: DropInfo<T>) => void; }): DragLayer<T>
189
+ - DialogueLine (type): type DialogueLine = { speaker: string; text: string } | { choices: readonly DialogueChoice[] }
190
+ - DisplayProfile (interface): interface DisplayProfile
237
191
  - DragGhost (function): function DragGhost<T>({ layer, className, style, children, }: { layer: DragLayer<T>; className?: string; style?: CSSProperties; children?: (payload: DragPayload<T>) => ReactNode; }): React.JSX.Element | null
238
- - DraggableCard (function): function DraggableCard<T>({ id, value, layer, className, children, onRotate, }: { id: string; value: T; layer: DragLayer<T>; className?: string; children?: ReactNode; onRotate?: boolean; }): React.JSX.Element
239
- - DropZone (function): function DropZone<T>({ id, layer, className, activeClassName, cellSize, children, }: { id: string; layer: DragLayer<T>; className?: string; activeClassName?: string; cellSize?: number; children?: ReactNode; }): React.JSX.Element
192
+ - DragLayer (interface): interface DragLayer<T>
240
193
  - DragPayload (interface): interface DragPayload<T>
241
194
  - DragState (interface): interface DragState<T>
195
+ - DraggableCard (function): function DraggableCard<T>({ id, value, layer, className, children, onRotate, }: { id: string; value: T; layer: DragLayer<T>; className?: string; children?: ReactNode; onRotate?: boolean; }): React.JSX.Element
242
196
  - DropInfo (interface): interface DropInfo<T>
243
- - DragLayer (interface): interface DragLayer<T>
244
- - useDisplayProfile (function): function useDisplayProfile(): DisplayProfile
245
- - DisplayProfile (interface): interface DisplayProfile
246
- - useMarkers (function): function useMarkers(markers: MarkerSet): readonly MapMarker[]
247
- - useFog (function): function useFog(fog: FogField): ReturnType<FogField["cells"]>
248
- - Minimap (function): function Minimap({ markers, center, worldRadius, fog, size = 176, heading = 0, rotate = false, kindStyles = DEFAULT_MARKER_KINDS, background, mapBounds, className, title = "Map", children, }: MinimapProps): ReactNode — Framed circular minimap: optional baked terrain background, reveal-on-event fog overlay, categorized marker icons, and a facing arrow. Reads a core `MarkerSet` / `FogField`; supply your own `kindStyles` palette to reskin.
249
- - Compass (function): function Compass({ heading, center, markers, width = 340, fov = (Math.PI * 2) / 3, kindStyles = DEFAULT_MARKER_KINDS, className, }: CompassProps): ReactNode Horizontal compass strip centered on the player's facing bearing, with the eight cardinals and optional marker pips (bearing to each `MarkerSet` entry).
250
- - WorldMap (function): function WorldMap({ markers, bounds, player, heading = 0, fog, background, width = 520, height, kindStyles = DEFAULT_MARKER_KINDS, className, title = "World Map", onClose, }: WorldMapProps): ReactNode Full-bounds top-down world map (the "press M" overlay): baked terrain background, reveal-on-event fog, all markers with labels, and the player. Rectangular linear projection over the supplied world `bounds`.
251
- - MinimapProps (interface): interface MinimapProps
197
+ - DropZone (function): function DropZone<T>({ id, layer, className, activeClassName, cellSize, children, }: { id: string; layer: DragLayer<T>; className?: string; activeClassName?: string; cellSize?: number; children?: ReactNode; }): React.JSX.Element
198
+ - EmoteWheel (function): function EmoteWheel({ emotes, radius, open = true, className, emoteClassName, onPlayed, onRejected, renderEmote, }: { emotes: readonly string[]; radius?: number; open?: boolean; className?: string; emoteClassName?: string; onPlayed?: (emoteId: string) => void; onRejected?: (reason: string) => void; …
199
+ - EventMeterView (interface): interface EventMeterView
200
+ - EventfulEngineStore (interface): interface EventfulEngineStore<TEventMap extends object>
201
+ - FriendRequestsList (function): function FriendRequestsList({ className, rowClassName, acceptClassName, declineClassName, emptyState, renderRequest, }: { className?: string; rowClassName?: string; acceptClassName?: string; declineClassName?: string; emptyState?: ReactNode; renderRequest?: (request: FriendRequestEntry) => ReactNode…
202
+ - FriendRow (function): function FriendRow({ friend, className, dotClassName, children, }: { friend: FriendEntry; className?: string; dotClassName?: string; children?: ReactNode; }): React.JSX.Element
203
+ - FriendsList (function): function FriendsList({ className, rowClassName, dotClassName, emptyState, renderFriend, }: { className?: string; rowClassName?: string; dotClassName?: string; emptyState?: ReactNode; renderFriend?: (friend: FriendEntry) => ReactNode; }): React.JSX.Element
204
+ - GameIdentityProvider (function): function GameIdentityProvider({ source, children, }: { source: IdentitySource; children?: ReactNode; }): React.JSX.Element
205
+ - GamePreviewComponent (type): type GamePreviewComponent = ComponentType<GamePreviewProps>
206
+ - GamePreviewProps (type): type GamePreviewProps = { className?: string; }
207
+ - GamePreviewStates (type): type GamePreviewStates = Record<string, GamePreviewComponent>
208
+ - GameProvider (function): function GameProvider({ context, children }: { context: GameContext; children?: ReactNode }): React.JSX.Element
209
+ - HealthBar (function): function HealthBar({ instanceId, statId, className, fillClassName, }: { instanceId: string; statId: string; className?: string; fillClassName?: string; }): React.JSX.Element | null
210
+ - HudCanvas (function): function HudCanvas({ layout, editChord, compactScale, className, style, children, }: { layout: HudLayoutStore; editChord?: HudEditChord | false; /** Zoom applied to the whole HUD on compact displays. Default 0.85. */ compactScale?: number; className?: string; style?: CSSProperties; children?: ReactN… — Full-viewport HUD surface. Panels declared with `HudPanel` flow into nine anchor regions and stack automatically with a gap — no per-panel pixel offsets, no manual clearance for sibling panels, the touch-control dock (`--jg-hud-dock-clearance`), or device safe areas. On compact displays the whole surface scales down and each panel applies its `compact` behavior.
211
+ - HudCompactMode (type): type HudCompactMode = "keep" | "chip" | "hide" — How a panel behaves on compact (phone-scale) displays. `keep` stays visible at the global compact scale, `chip` collapses to a small tap-to-expand pill, `hide` unmounts entirely.
212
+ - HudEditChord (interface): interface HudEditChord
213
+ - HudPanel (function): function HudPanel({ id, anchor = "top-left", order, compact: compactMode = "keep", chip, interactive, inset, locked, className, style, children, }: { id: string; anchor?: HudAnchor; /** Stack position within the region, ascending outward from the screen edge. Default 0. */ order?: number; /** Behavi… — A HUD block that lives in one of the nine anchor regions. Panels sharing a region stack outward from the screen edge in ascending `order`. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement. On compact displays custom placements are ignored and the `compact` behavior applies.
214
+ - HudViewportContextValue (interface): interface HudViewportContextValue
215
+ - HudViewportProvider (function): function HudViewportProvider({ platforms, config, userScale, children, }: { platforms: readonly HudPlatform[] | undefined; config: HudViewportConfig | undefined; userScale?: number; children?: ReactNode; }): React.JSX.Element — Mounted by the shell around `GameUI` so every `HudCanvas` inside the game picks up the game's `platforms`/`hudFit` declaration and the player's UI scale setting without any game-side wiring.
216
+ - IdentitySource (interface): interface IdentitySource
217
+ - InviteToWorldButton (function): function InviteToWorldButton({ toUserId, target, className, children, onInvited, onRejected, }: { toUserId: string; target: WorldInviteTarget; className?: string; children?: ReactNode; onInvited?: (inviteId: string) => void; onRejected?: (reason: string) => void; }): React.JSX.Element
218
+ - JoinByCode (function): function JoinByCode({ onJoin, className, inputClassName, buttonClassName, placeholder, children, }: { onJoin: (code: string) => void; className?: string; inputClassName?: string; buttonClassName?: string; placeholder?: string; children?: ReactNode; }): React.JSX.Element
219
+ - KeybindRow (function): function KeybindRow({ action, keys, className, }: { action: string; keys: readonly string[]; className?: string; }): React.JSX.Element
220
+ - LeavePartyButton (function): function LeavePartyButton({ className, children, }: { className?: string; children?: ReactNode; }): React.JSX.Element | null
221
+ - LevelUpFlash (function): function LevelUpFlash({ stat, durationMs = 1600, className, children, renderFlash, }: { stat?: string; durationMs?: number; className?: string; children?: ReactNode; renderFlash?: (event: StatLevelUpEvent) => ReactNode; }): React.JSX.Element | null
252
222
  - MapBounds (interface): interface MapBounds
253
- - CompassProps (interface): interface CompassProps
223
+ - MicToggle (function): function MicToggle({ voice, className, mutedLabel, unmutedLabel, }: { voice: VoiceState; className?: string; mutedLabel?: ReactNode; unmutedLabel?: ReactNode; }): React.JSX.Element
224
+ - Minimap (function): function Minimap({ markers, center, worldRadius, fog, size = 176, facingYaw = 0, rotate = false, kindStyles = DEFAULT_MARKER_KINDS, background, mapBounds, routes, zones, cellStates, onWorldClick, className, title = "Map", children, }: MinimapProps): ReactNode — Framed circular minimap: optional baked terrain background, reveal-on-event fog overlay, categorized marker icons, and a facing arrow. Reads a core `MarkerSet` / `FogField`; supply your own `kindStyles` palette to reskin.
225
+ - MinimapProps (interface): interface MinimapProps
226
+ - PartyFrame (function): function PartyFrame({ className, rowClassName, dotClassName, emptyState, renderMember, }: { className?: string; rowClassName?: string; dotClassName?: string; emptyState?: ReactNode; renderMember?: (member: PartyMemberEntry) => ReactNode; }): React.JSX.Element
227
+ - PartyInviteToast (function): function PartyInviteToast({ className, acceptClassName, declineClassName, renderInvite, }: { className?: string; acceptClassName?: string; declineClassName?: string; renderInvite?: (invite: PartyInviteEntry) => ReactNode; }): React.JSX.Element | null
228
+ - PartyMemberRow (function): function PartyMemberRow({ member, className, dotClassName, children, }: { member: PartyMemberEntry; className?: string; dotClassName?: string; children?: ReactNode; }): React.JSX.Element
229
+ - PresenceDot (function): function PresenceDot({ userId, className }: { userId: string; className?: string }): React.JSX.Element
230
+ - ProximityPrompt (function): function ProximityPrompt({ prompt, className, }: { prompt: ProximityPromptDef; className?: string; }): React.JSX.Element
231
+ - PushToTalkButton (function): function PushToTalkButton({ voice, className, children, }: { voice: VoiceState; className?: string; children?: ReactNode; }): React.JSX.Element
232
+ - QteTrack (function): function QteTrack({ steps, startedAt, className, stepClassName, activeClassName, doneClassName, }: { steps: readonly QteStep[]; startedAt: number; className?: string; stepClassName?: string; activeClassName?: string; doneClassName?: string; }): React.JSX.Element
233
+ - QuickMatchButton (function): function QuickMatchButton({ listings, onJoin, onNoMatch, filter, className, children, }: { listings: readonly SessionListing[]; onJoin: (listing: SessionListing) => void; onNoMatch?: () => void; filter?: MatchFilter; className?: string; children?: ReactNode; }): React.JSX.Element
234
+ - ReadableEngineStore (interface): interface ReadableEngineStore<TState>
235
+ - RequireSession (function): function RequireSession({ fallback, loading, children, }: { fallback?: ReactNode; loading?: ReactNode; children?: ReactNode; }): React.JSX.Element
236
+ - Screen (function): function Screen({ id, open = true, className, children, }: { id: string; open?: boolean; className?: string; children?: ReactNode; }): React.JSX.Element | null
237
+ - SettingsActionView (interface): interface SettingsActionView — A resolved game-state action — `run` is already bound to the game context and closes the menu.
238
+ - SettingsCategoryView (interface): interface SettingsCategoryView
239
+ - SettingsController (interface): interface SettingsController — The live settings controller — every category/row/keybind/action plus open-state. Render it any way you like or drive the engine menu.
240
+ - SettingsControllerProvider (function): function SettingsControllerProvider({ controller, children, }: { controller: SettingsController; children: ReactNode; }): React.JSX.Element
241
+ - SettingsKeybindRow (interface): interface SettingsKeybindRow
242
+ - SettingsProvider (function): function SettingsProvider({ store, children }: { store: SettingsStore; children: ReactNode }): React.JSX.Element
243
+ - SettingsRow (interface): interface SettingsRow
244
+ - SettingsTrigger (function): function SettingsTrigger({ className, children, label = "Settings", }: { className?: string; children?: ReactNode; label?: string; }): React.JSX.Element | null — Inline settings entry — drop it anywhere in your game's menu or HUD; it opens the themed settings menu. Headless: pass `className` for placement/skin and `children` to replace the default gear glyph. Renders nothing when the game has no settings to show, so it never leaves a dead button behind.
245
+ - SignOutButton (function): function SignOutButton({ className, children, }: { className?: string; children?: ReactNode; }): React.JSX.Element | null
246
+ - SkillCheckBar (function): function SkillCheckBar({ config, startedAt, className, trackClassName, zoneClassName, markerClassName, renderStatus, }: { config: SkillCheckConfig; startedAt: number; className?: string; trackClassName?: string; zoneClassName?: string; markerClassName?: string; renderStatus?: (result: SkillCheckResu…
247
+ - SlotGrid (function): function SlotGrid({ inventoryId, className, renderSlot, }: { inventoryId: string; className?: string; renderSlot?: (slot: InventorySlot, index: number) => ReactNode; }): React.JSX.Element
248
+ - SpeakingIndicator (function): function SpeakingIndicator({ voice, userId, className, threshold = 0.01, children, }: { voice: VoiceState; userId: string; className?: string; threshold?: number; children?: ReactNode; }): React.JSX.Element
249
+ - ToastStack (function): function ToastStack({ action, limit = 4, className, renderToast, }: { action: string; limit?: number; className?: string; renderToast?: (entry: FeedEntry, index: number) => ReactNode; }): React.JSX.Element | null
250
+ - UseAxisChannelResult (interface): interface UseAxisChannelResult
251
+ - UseVoiceOptions (interface): interface UseVoiceOptions
252
+ - UserBadge (function): function UserBadge({ className, avatarClassName, nameClassName, renderBadge, }: { className?: string; avatarClassName?: string; nameClassName?: string; renderBadge?: (session: AuthSession) => ReactNode; }): React.JSX.Element | null
253
+ - VoiceRoster (function): function VoiceRoster({ voice, className, participantClassName, renderParticipant, }: { voice: VoiceState; className?: string; participantClassName?: string; renderParticipant?: (participant: VoiceParticipant, gain: number) => ReactNode; }): React.JSX.Element
254
+ - VoiceState (interface): interface VoiceState
255
+ - WorldBrowser (function): function WorldBrowser({ listings, onJoin, className, rowClassName, joinClassName, emptyState, renderListing, }: { listings: readonly SessionListing[]; onJoin: (listing: SessionListing) => void; className?: string; rowClassName?: string; joinClassName?: string; emptyState?: ReactNode; renderListing?:…
256
+ - WorldBrowserState (interface): interface WorldBrowserState
257
+ - WorldInviteToast (function): function WorldInviteToast({ className, acceptClassName, declineClassName, onAccepted, renderInvite, }: { className?: string; acceptClassName?: string; declineClassName?: string; onAccepted: (target: WorldInviteTarget) => void; renderInvite?: (invite: WorldInvite) => ReactNode; }): React.JSX.Element …
258
+ - WorldMap (function): function WorldMap({ markers, bounds, player, facingYaw = 0, fog, background, width = 520, height, kindStyles = DEFAULT_MARKER_KINDS, routes, zones, cellStates, onWorldClick, className, title = "World Map", onClose, }: WorldMapProps): ReactNode — Full-bounds top-down world map (the "press M" overlay): baked terrain background, reveal-on-event fog, all markers with labels, and the player. Rectangular linear projection over the supplied world `bounds`.
254
259
  - WorldMapProps (interface): interface WorldMapProps
260
+ - abilityKitNeedsHeartbeat (function): function abilityKitNeedsHeartbeat(kit: AbilityKit, resourceAvailable?: number): boolean
261
+ - betterAuthIdentity (function): function betterAuthIdentity(state: BetterAuthSessionState, options?: { signOut?: () => void }): IdentitySource
262
+ - chatTransportFromSync (function): function chatTransportFromSync(sync: ChatSync): ChatTransport — Lifts a callback-style ChatSync (e.g. createWsBackend().chatSyncFor(serverId)) into the hook-shaped ChatTransport contract. Create once per sync — outside render or inside useMemo — so subscriptions survive re-renders.
263
+ - clerkIdentity (function): function clerkIdentity(state: ClerkUserState, options?: { signOut?: () => void }): IdentitySource
264
+ - createHeldKeyTracker (function): function createHeldKeyTracker(target: HeldKeyEventTarget): { isDown: (code: string) => boolean; dispose: () => void; }
265
+ - eventMeterNeedsHeartbeat (function): function eventMeterNeedsHeartbeat(meter: EventMeter, previous: EventMeterView | null): boolean
266
+ - guestIdentity (function): function guestIdentity(seed?: string): IdentitySource
267
+ - latestChatBubbles (function): function latestChatBubbles(messages: readonly ChatMessage[], nowMs: number, ttlMs: number): ChatBubble[]
268
+ - localPlayerEntity (function): function localPlayerEntity(ctx: GameContext): SceneEntity | null
269
+ - paintQteStepDom (function): function paintQteStepDom(elements: ReadonlyMap<string, HTMLElement>, steps: readonly QteStep[], elapsed: number, activeId: string | null, stepClassName?: string, activeClassName?: string, doneClassName?: string): void
270
+ - paintSkillCheckDom (function): function paintSkillCheckDom(root: HTMLElement, zone: HTMLElement, marker: HTMLElement, config: SkillCheckConfig, result: SkillCheckResult): void
271
+ - resolveDialogueInvoke (function): function resolveDialogueInvoke(choice: DialogueChoice, result: CheckResult | null): { command: string; args?: unknown } | null
272
+ - useAbilitySlot (function): function useAbilitySlot(kit: AbilityKit, slotId: string, resourceAvailable?: number, options?: AbilitySlotBindingOptions): AbilitySlotSnapshot | null
273
+ - useAbilitySlots (function): function useAbilitySlots(kit: AbilityKit, resourceAvailable?: number, options?: AbilitySlotBindingOptions): AbilitySlotSnapshot[]
274
+ - useActivePrompt (function): function useActivePrompt<T extends PositionedPrompt>(prompts?: readonly T[]): T | null
275
+ - useAuthedPlayer (function): function useAuthedPlayer(options?: { guestSeed?: string }): PlayerIdentity | null
276
+ - useAxisChannel (function): function useAxisChannel(config: AxisChannelConfig): UseAxisChannelResult — Wires useHeldKeys into a fresh AxisChannel, ready for a per-frame `channel.sample(dt, isDown)`. The channel is recreated when `config` identity changes, so pass a stable config (useMemo/module constant at the call site) unless a rebind is intended.
277
+ - useChat (function): function useChat(channelId: string, options?: { limit?: number }): ChatMessage[]
278
+ - useChatBubbles (function): function useChatBubbles(options?: ChatBubblesOptions): readonly ChatBubble[]
279
+ - useCurrency (function): function useCurrency(currencyId: string): number
280
+ - useDisplayProfile (function): function useDisplayProfile(): DisplayProfile
281
+ - useDragLayer (function): function useDragLayer<T>(options?: { onDrop?: (info: DropInfo<T>) => void; }): DragLayer<T>
282
+ - useEngineEvent (function): function useEngineEvent<TEventMap extends object, K extends keyof TEventMap>(store: EventfulEngineStore<TEventMap>, eventName: K, handler: (payload: TEventMap[K]) => void): void
283
+ - useEngineState (function): function useEngineState<TState>(store: ReadableEngineStore<TState>): TState
284
+ - useEngineStore (function): function useEngineStore<TState, TSelected>(store: ReadableEngineStore<TState>, selector: (state: TState) => TSelected, isEqual: (previous: TSelected, next: TSelected) => boolean = Object.is): TSelected
285
+ - useEntityChatBubble (function): function useEntityChatBubble(instanceId: string, options?: ChatBubblesOptions): ChatBubble | null
286
+ - useEntityStat (function): function useEntityStat(instanceId: string, statId: string): StatValue | null
287
+ - useEventMeter (function): function useEventMeter(meter: EventMeter, options?: AbilitySlotBindingOptions): EventMeterView
288
+ - useFeed (function): function useFeed({ action, limit }: { action: string; limit?: number }): FeedEntry[]
289
+ - useFog (function): function useFog(fog: FogField): ReturnType<FogField["cells"]>
290
+ - useFriendRequests (function): function useFriendRequests(): FriendRequestEntry[]
291
+ - useFriends (function): function useFriends(): FriendEntry[]
292
+ - useGame (function): function useGame(): { commands: GameContext["game"]["commands"]; events: GameEvents }
293
+ - useGameClock (function): function useGameClock(): ClockSnapshot & { controls: SimClock }
294
+ - useGameContext (function): function useGameContext(): GameContext
295
+ - useGamePhase (function): function useGamePhase(): { phase: GamePhase; setPhase: (phase: GamePhase) => void } — Live run phase + a setter that also gates the shell's touch controls. `menu`/`paused`/`ended` hide the touch dock; `playing` shows it.
296
+ - useGameStore (function): function useGameStore<T>(selector: (ctx: GameContext) => T, isEqual: (previous: T, next: T) => boolean = Object.is): T
297
+ - useHasSettings (function): function useHasSettings(): boolean — True when the game has any setting or game-action to show — gate your own settings entry on it.
298
+ - useHeldKeys (function): function useHeldKeys(): (code: string) => boolean — Held-key predicate backed by window keydown/keyup/blur listeners (blur clears held state so a released-off-window key doesn't stick). SSR-safe: listeners attach in an effect, never at module scope. The returned predicate is stable across renders.
299
+ - useHudLayout (function): function useHudLayout(options?: { storageKey?: string; snap?: number; locked?: boolean; }): HudLayoutStore
300
+ - useHudViewport (function): function useHudViewport(): HudViewportContextValue | null
301
+ - useInventory (function): function useInventory(inventoryId: string): readonly InventorySlot[]
302
+ - useLeaderboard (function): function useLeaderboard(stat: string, options: { scope: LeaderboardScope; limit?: number }): { userId: string; value: number }[]
303
+ - useLocalPlayerDead (function): function useLocalPlayerDead(healthStatId = "health"): boolean
304
+ - useMarkers (function): function useMarkers(markers: MarkerSet): readonly MapMarker[]
305
+ - useNearestWorldItem (function): function useNearestWorldItem(radius: number): WorldItemRecord | null — Nearest ground item within `radius` of the local player — drives a pickup prompt/highlight.
306
+ - useParty (function): function useParty(): PartyMemberEntry[]
307
+ - usePartyInvites (function): function usePartyInvites(): PartyInviteEntry[]
308
+ - usePlayer (function): function usePlayer(): { userId: string; isNew: boolean }
309
+ - usePresence (function): function usePresence(userId: string): PresenceInfo
310
+ - useQuestJournal (function): function useQuestJournal(): QuestInstance[]
311
+ - useRoster (function): function useRoster(userId?: string): readonly RosterEntry[]
312
+ - useSceneEntities (function): function useSceneEntities(): readonly SceneEntity[]
313
+ - useSceneObjects (function): function useSceneObjects(): readonly SceneObject[]
314
+ - useSession (function): function useSession(): IdentitySource
315
+ - useSetting (function): function useSetting<T extends SettingValue>(id: string, fallback: T): readonly [T, (value: SettingValue) => void] — Read + write one persisted setting; re-renders when the value changes anywhere.
316
+ - useSettings (function): function useSettings(): SettingsController — The engine settings controller for the current game — render your own settings UI from `categories`, or open the built-in menu with `open()`. Null-safe stub when mounted outside the shell.
317
+ - useSettingsStore (function): function useSettingsStore(): SettingsStore — The shared settings store, or a standalone one when no provider is mounted (game code read outside the shell).
318
+ - useTarget (function): function useTarget(fromInstanceId: string): string | null
319
+ - useVoice (function): function useVoice(options?: UseVoiceOptions): VoiceState — Mic capture + push-to-talk + channel roster over the VoiceTransport signaling seam. Transmission gates the captured tracks' `enabled` flag; the media plane that actually moves audio bytes (WebRTC/SFU) stays behind the transport, host-supplied. Call once per voice channel and hand the returned state to the voice components.
320
+ - useWorldBrowser (function): function useWorldBrowser(options: { fetchSessions: () => Promise<readonly SessionListing[]>; filter?: MatchFilter; limit?: number; refreshMs?: number; }): WorldBrowserState — Polls a host-supplied session fetcher (e.g. createWsBackend().browse) and filters through matchmaking's browseSessions. fetchSessions must be identity-stable (wrap in useCallback at the call site) or every render refetches.
321
+ - useWorldInvites (function): function useWorldInvites(): WorldInvite[]
322
+ - useWorldItems (function): function useWorldItems(): readonly WorldItemRecord[]
255
323
 
256
324
  ### @jgengine/react/liveBind
257
325
 
258
- - useFrameBind (function): function useFrameBind<T, E extends Element = Element>(ref: { current: E | null }, get: () => T, apply: (value: T, element: E) => void): void Drive a DOM/SVG element from a per-frame value without re-rendering React. Runs one requestAnimationFrame loop, reads `get()` each frame, and calls `apply(value, element)` so HUDs bound to live engine state (speed, pose) never re-render and never lag. `get`/`apply` may change without restarting.
259
- - LiveText (function): function LiveText({ get, format, className, style, }: { get: () => number | string; format?: (value: number | string) => string; className?: string; style?: CSSProperties; }): React.JSX.Element — A `<span>` whose text tracks a live value every frame (the 90% case of useFrameBind). `<LiveText get={() => groundSpeed(car) * KMH} format={Math.round} />`.
326
+ - LiveText (function): function LiveText({ get, format, className, style, }: { get: () => number | string; format?: (value: number | string) => string; className?: string; style?: CSSProperties; }): React.JSX.Element
327
+ - frameBindSubscriberCount (function): function frameBindSubscriberCount(): number
328
+ - subscribeFrameBind (function): function subscribeFrameBind(subscriber: FrameSubscriber): () => void
329
+ - useFrameBind (function): function useFrameBind<T, E extends Element = Element>(ref: { current: E | null }, get: () => T, apply: (value: T, element: E) => void): void
260
330
 
261
331
  ### @jgengine/react/map
262
332
 
263
- - useMarkers (function): function useMarkers(markers: MarkerSet): readonly MapMarker[]
264
- - useFog (function): function useFog(fog: FogField): ReturnType<FogField["cells"]>
265
- - Minimap (function): function Minimap({ markers, center, worldRadius, fog, size = 176, heading = 0, rotate = false, kindStyles = DEFAULT_MARKER_KINDS, background, mapBounds, className, title = "Map", children, }: MinimapProps): ReactNode — Framed circular minimap: optional baked terrain background, reveal-on-event fog overlay, categorized marker icons, and a facing arrow. Reads a core `MarkerSet` / `FogField`; supply your own `kindStyles` palette to reskin.
266
- - Compass (function): function Compass({ heading, center, markers, width = 340, fov = (Math.PI * 2) / 3, kindStyles = DEFAULT_MARKER_KINDS, className, }: CompassProps): ReactNode — Horizontal compass strip centered on the player's facing bearing, with the eight cardinals and optional marker pips (bearing to each `MarkerSet` entry).
267
- - WorldMap (function): function WorldMap({ markers, bounds, player, heading = 0, fog, background, width = 520, height, kindStyles = DEFAULT_MARKER_KINDS, className, title = "World Map", onClose, }: WorldMapProps): ReactNode — Full-bounds top-down world map (the "press M" overlay): baked terrain background, reveal-on-event fog, all markers with labels, and the player. Rectangular linear projection over the supplied world `bounds`.
268
- - MinimapProps (interface): interface MinimapProps
269
- - MapBounds (interface): interface MapBounds
333
+ - Compass (function): function Compass({ facingYaw, center, markers, width = 340, fov = (Math.PI * 2) / 3, kindStyles = DEFAULT_MARKER_KINDS, className, }: CompassProps): ReactNode — Horizontal compass strip centered on the player's facing direction, with the eight cardinals and optional marker pips (bearing to each `MarkerSet` entry).
270
334
  - CompassProps (interface): interface CompassProps
335
+ - MapBounds (interface): interface MapBounds
336
+ - Minimap (function): function Minimap({ markers, center, worldRadius, fog, size = 176, facingYaw = 0, rotate = false, kindStyles = DEFAULT_MARKER_KINDS, background, mapBounds, routes, zones, cellStates, onWorldClick, className, title = "Map", children, }: MinimapProps): ReactNode — Framed circular minimap: optional baked terrain background, reveal-on-event fog overlay, categorized marker icons, and a facing arrow. Reads a core `MarkerSet` / `FogField`; supply your own `kindStyles` palette to reskin.
337
+ - MinimapProps (interface): interface MinimapProps
338
+ - WorldMap (function): function WorldMap({ markers, bounds, player, facingYaw = 0, fog, background, width = 520, height, kindStyles = DEFAULT_MARKER_KINDS, routes, zones, cellStates, onWorldClick, className, title = "World Map", onClose, }: WorldMapProps): ReactNode — Full-bounds top-down world map (the "press M" overlay): baked terrain background, reveal-on-event fog, all markers with labels, and the player. Rectangular linear projection over the supplied world `bounds`.
271
339
  - WorldMapProps (interface): interface WorldMapProps
340
+ - useFog (function): function useFog(fog: FogField): ReturnType<FogField["cells"]>
341
+ - useMarkers (function): function useMarkers(markers: MarkerSet): readonly MapMarker[]
342
+
343
+ ### @jgengine/react/preview
344
+
345
+ - GamePreviewComponent (type): type GamePreviewComponent = ComponentType<GamePreviewProps>
346
+ - GamePreviewProps (type): type GamePreviewProps = { className?: string; }
347
+ - GamePreviewStates (type): type GamePreviewStates = Record<string, GamePreviewComponent>
272
348
 
273
349
  ### @jgengine/react/provider
274
350
 
275
351
  - GameProvider (function): function GameProvider({ context, children }: { context: GameContext; children?: ReactNode }): React.JSX.Element
276
352
  - useGameContext (function): function useGameContext(): GameContext
277
353
 
354
+ ### @jgengine/react/selectSnapshot
355
+
356
+ - SelectCache (type): type SelectCache<TSnapshot, TSelected> = { ready: boolean; snapshot: TSnapshot; value: TSelected; }
357
+ - createSelectCache (function): function createSelectCache<TSnapshot, TSelected>(): SelectCache<TSnapshot, TSelected>
358
+ - readSelectSnapshot (function): function readSelectSnapshot<TSnapshot, TSelected>(cache: SelectCache<TSnapshot, TSelected>, snapshot: TSnapshot, select: (snapshot: TSnapshot) => TSelected, isEqual: (previous: TSelected, next: TSelected) => boolean = Object.is): TSelected
359
+
360
+ ### @jgengine/react/settings
361
+
362
+ - SettingsActionView (interface): interface SettingsActionView — A resolved game-state action — `run` is already bound to the game context and closes the menu.
363
+ - SettingsCategoryView (interface): interface SettingsCategoryView
364
+ - SettingsController (interface): interface SettingsController — The live settings controller — every category/row/keybind/action plus open-state. Render it any way you like or drive the engine menu.
365
+ - SettingsControllerProvider (function): function SettingsControllerProvider({ controller, children, }: { controller: SettingsController; children: ReactNode; }): React.JSX.Element
366
+ - SettingsKeybindRow (interface): interface SettingsKeybindRow
367
+ - SettingsProvider (function): function SettingsProvider({ store, children }: { store: SettingsStore; children: ReactNode }): React.JSX.Element
368
+ - SettingsRow (interface): interface SettingsRow
369
+ - SettingsTrigger (function): function SettingsTrigger({ className, children, label = "Settings", }: { className?: string; children?: ReactNode; label?: string; }): React.JSX.Element | null — Inline settings entry — drop it anywhere in your game's menu or HUD; it opens the themed settings menu. Headless: pass `className` for placement/skin and `children` to replace the default gear glyph. Renders nothing when the game has no settings to show, so it never leaves a dead button behind.
370
+ - useHasSettings (function): function useHasSettings(): boolean — True when the game has any setting or game-action to show — gate your own settings entry on it.
371
+ - useSetting (function): function useSetting<T extends SettingValue>(id: string, fallback: T): readonly [T, (value: SettingValue) => void] — Read + write one persisted setting; re-renders when the value changes anywhere.
372
+ - useSettings (function): function useSettings(): SettingsController — The engine settings controller for the current game — render your own settings UI from `categories`, or open the built-in menu with `open()`. Null-safe stub when mounted outside the shell.
373
+ - useSettingsStore (function): function useSettingsStore(): SettingsStore — The shared settings store, or a standalone one when no provider is mounted (game code read outside the shell).
374
+
375
+ ### @jgengine/react/skillCheckPaint
376
+
377
+ - paintQteStepDom (function): function paintQteStepDom(elements: ReadonlyMap<string, HTMLElement>, steps: readonly QteStep[], elapsed: number, activeId: string | null, stepClassName?: string, activeClassName?: string, doneClassName?: string): void
378
+ - paintSkillCheckDom (function): function paintSkillCheckDom(root: HTMLElement, zone: HTMLElement, marker: HTMLElement, config: SkillCheckConfig, result: SkillCheckResult): void
379
+
278
380
  ### @jgengine/react/social
279
381
 
280
- - PresenceDot (function): function PresenceDot({ userId, className }: { userId: string; className?: string }): React.JSX.Element
281
- - FriendRow (function): function FriendRow({ friend, className, dotClassName, children, }: { friend: FriendEntry; className?: string; dotClassName?: string; children?: ReactNode; }): React.JSX.Element
282
- - FriendsList (function): function FriendsList({ className, rowClassName, dotClassName, emptyState, renderFriend, }: { className?: string; rowClassName?: string; dotClassName?: string; emptyState?: ReactNode; renderFriend?: (friend: FriendEntry) => ReactNode; }): React.JSX.Element
283
382
  - AddFriendButton (function): function AddFriendButton({ toUserId, className, children, onRequested, onRejected, }: { toUserId: string; className?: string; children?: ReactNode; onRequested?: (requestId: string) => void; onRejected?: (reason: string) => void; }): React.JSX.Element
383
+ - EmoteWheel (function): function EmoteWheel({ emotes, radius, open = true, className, emoteClassName, onPlayed, onRejected, renderEmote, }: { emotes: readonly string[]; radius?: number; open?: boolean; className?: string; emoteClassName?: string; onPlayed?: (emoteId: string) => void; onRejected?: (reason: string) => void; …
284
384
  - FriendRequestsList (function): function FriendRequestsList({ className, rowClassName, acceptClassName, declineClassName, emptyState, renderRequest, }: { className?: string; rowClassName?: string; acceptClassName?: string; declineClassName?: string; emptyState?: ReactNode; renderRequest?: (request: FriendRequestEntry) => ReactNode…
285
- - PartyMemberRow (function): function PartyMemberRow({ member, className, dotClassName, children, }: { member: PartyMemberEntry; className?: string; dotClassName?: string; children?: ReactNode; }): React.JSX.Element
286
- - PartyFrame (function): function PartyFrame({ className, rowClassName, dotClassName, emptyState, renderMember, }: { className?: string; rowClassName?: string; dotClassName?: string; emptyState?: ReactNode; renderMember?: (member: PartyMemberEntry) => ReactNode; }): React.JSX.Element
287
- - PartyInviteToast (function): function PartyInviteToast({ className, acceptClassName, declineClassName, renderInvite, }: { className?: string; acceptClassName?: string; declineClassName?: string; renderInvite?: (invite: PartyInviteEntry) => ReactNode; }): React.JSX.Element | null
288
- - LeavePartyButton (function): function LeavePartyButton({ className, children, }: { className?: string; children?: ReactNode; }): React.JSX.Element | null
289
- - WorldInviteToast (function): function WorldInviteToast({ className, acceptClassName, declineClassName, onAccepted, renderInvite, }: { className?: string; acceptClassName?: string; declineClassName?: string; onAccepted: (target: WorldInviteTarget) => void; renderInvite?: (invite: WorldInvite) => ReactNode; }): React.JSX.Element …
385
+ - FriendRow (function): function FriendRow({ friend, className, dotClassName, children, }: { friend: FriendEntry; className?: string; dotClassName?: string; children?: ReactNode; }): React.JSX.Element
386
+ - FriendsList (function): function FriendsList({ className, rowClassName, dotClassName, emptyState, renderFriend, }: { className?: string; rowClassName?: string; dotClassName?: string; emptyState?: ReactNode; renderFriend?: (friend: FriendEntry) => ReactNode; }): React.JSX.Element
290
387
  - InviteToWorldButton (function): function InviteToWorldButton({ toUserId, target, className, children, onInvited, onRejected, }: { toUserId: string; target: WorldInviteTarget; className?: string; children?: ReactNode; onInvited?: (inviteId: string) => void; onRejected?: (reason: string) => void; }): React.JSX.Element
291
- - WorldBrowser (function): function WorldBrowser({ listings, onJoin, className, rowClassName, joinClassName, emptyState, renderListing, }: { listings: readonly SessionListing[]; onJoin: (listing: SessionListing) => void; className?: string; rowClassName?: string; joinClassName?: string; emptyState?: ReactNode; renderListing?:…
292
388
  - JoinByCode (function): function JoinByCode({ onJoin, className, inputClassName, buttonClassName, placeholder, children, }: { onJoin: (code: string) => void; className?: string; inputClassName?: string; buttonClassName?: string; placeholder?: string; children?: ReactNode; }): React.JSX.Element
389
+ - LeavePartyButton (function): function LeavePartyButton({ className, children, }: { className?: string; children?: ReactNode; }): React.JSX.Element | null
390
+ - PartyFrame (function): function PartyFrame({ className, rowClassName, dotClassName, emptyState, renderMember, }: { className?: string; rowClassName?: string; dotClassName?: string; emptyState?: ReactNode; renderMember?: (member: PartyMemberEntry) => ReactNode; }): React.JSX.Element
391
+ - PartyInviteToast (function): function PartyInviteToast({ className, acceptClassName, declineClassName, renderInvite, }: { className?: string; acceptClassName?: string; declineClassName?: string; renderInvite?: (invite: PartyInviteEntry) => ReactNode; }): React.JSX.Element | null
392
+ - PartyMemberRow (function): function PartyMemberRow({ member, className, dotClassName, children, }: { member: PartyMemberEntry; className?: string; dotClassName?: string; children?: ReactNode; }): React.JSX.Element
393
+ - PresenceDot (function): function PresenceDot({ userId, className }: { userId: string; className?: string }): React.JSX.Element
293
394
  - QuickMatchButton (function): function QuickMatchButton({ listings, onJoin, onNoMatch, filter, className, children, }: { listings: readonly SessionListing[]; onJoin: (listing: SessionListing) => void; onNoMatch?: () => void; filter?: MatchFilter; className?: string; children?: ReactNode; }): React.JSX.Element
294
- - EmoteWheel (function): function EmoteWheel({ emotes, radius, open = true, className, emoteClassName, onPlayed, onRejected, renderEmote, }: { emotes: readonly string[]; radius?: number; open?: boolean; className?: string; emoteClassName?: string; onPlayed?: (emoteId: string) => void; onRejected?: (reason: string) => void;
395
+ - WorldBrowser (function): function WorldBrowser({ listings, onJoin, className, rowClassName, joinClassName, emptyState, renderListing, }: { listings: readonly SessionListing[]; onJoin: (listing: SessionListing) => void; className?: string; rowClassName?: string; joinClassName?: string; emptyState?: ReactNode; renderListing?:…
396
+ - WorldInviteToast (function): function WorldInviteToast({ className, acceptClassName, declineClassName, onAccepted, renderInvite, }: { className?: string; acceptClassName?: string; declineClassName?: string; onAccepted: (target: WorldInviteTarget) => void; renderInvite?: (invite: WorldInvite) => ReactNode; }): React.JSX.Element …
295
397
 
296
398
  ### @jgengine/react/voice
297
399
 
298
- - useVoice (function): function useVoice(options?: UseVoiceOptions): VoiceState — Mic capture + push-to-talk + channel roster over the VoiceTransport signaling seam. Transmission gates the captured tracks' `enabled` flag; the media plane that actually moves audio bytes (WebRTC/SFU) stays behind the transport, host-supplied. Call once per voice channel and hand the returned state to the voice components.
299
- - PushToTalkButton (function): function PushToTalkButton({ voice, className, children, }: { voice: VoiceState; className?: string; children?: ReactNode; }): React.JSX.Element
300
400
  - MicToggle (function): function MicToggle({ voice, className, mutedLabel, unmutedLabel, }: { voice: VoiceState; className?: string; mutedLabel?: ReactNode; unmutedLabel?: ReactNode; }): React.JSX.Element
401
+ - PushToTalkButton (function): function PushToTalkButton({ voice, className, children, }: { voice: VoiceState; className?: string; children?: ReactNode; }): React.JSX.Element
301
402
  - SpeakingIndicator (function): function SpeakingIndicator({ voice, userId, className, threshold = 0.01, children, }: { voice: VoiceState; userId: string; className?: string; threshold?: number; children?: ReactNode; }): React.JSX.Element
302
- - VoiceRoster (function): function VoiceRoster({ voice, className, participantClassName, renderParticipant, }: { voice: VoiceState; className?: string; participantClassName?: string; renderParticipant?: (participant: VoiceParticipant, gain: number) => ReactNode; }): React.JSX.Element
303
403
  - UseVoiceOptions (interface): interface UseVoiceOptions
404
+ - VoiceRoster (function): function VoiceRoster({ voice, className, participantClassName, renderParticipant, }: { voice: VoiceState; className?: string; participantClassName?: string; renderParticipant?: (participant: VoiceParticipant, gain: number) => ReactNode; }): React.JSX.Element
304
405
  - VoiceState (interface): interface VoiceState
406
+ - useVoice (function): function useVoice(options?: UseVoiceOptions): VoiceState — Mic capture + push-to-talk + channel roster over the VoiceTransport signaling seam. Transmission gates the captured tracks' `enabled` flag; the media plane that actually moves audio bytes (WebRTC/SFU) stays behind the transport, host-supplied. Call once per voice channel and hand the returned state to the voice components.
305
407
 
306
408
  ## Guides
307
409
 
308
- # JGengine — API Reference
410
+ # JGengine
411
+
412
+ JGengine is a pure-TypeScript game-engine SDK for games built by AI coding agents. Its skills teach the agent how to use the SDK. When a user says “make X with JGengine” or “make X with jgengine.com,” treat that as sufficient intake. If the skills are not installed, run `npx jgengine skills`; then use this skill and build on the SDK. Do not make the user discover or name skills.
413
+
414
+ ## Intake
415
+
416
+ State the reading as a short numbered list that is easy to correct, then proceed unless the user changes it. Prefer concrete values and actions over prose:
309
417
 
310
- The engine ships **verbs and primitives**; your game ships **nouns** (catalogs) and thin handlers. Read this before writing `game.config.ts` or any game content. Companion skills: **`jgengine-newgame`** (master blueprint + phased build to completion) and **`jgengine-verify`** (browserless scene gate) — read them before building. The UI quality bar lives in [`reference/ui-react.md`](reference/ui-react.md); asset sourcing lives in the **Assets** section below.
418
+ 1. **POV:** first-person
419
+ 2. **World:** custom 3D wasteland with three settlements
420
+ 3. **Core loop:** get quests by talking to people → defeat enemies → return to redeem quests
421
+ 4. **Interaction:** collect ground items by walking over them; interact with people and doors at close range
422
+ 5. **Combat:** ranged weapons, damage, death, loot
423
+ 6. **Progression:** inventory, currency, quest rewards, upgrades
424
+ 7. **Players:** single-player, or name the multiplayer topology and synchronized systems
425
+ 8. **UI:** visible controls, objective tracker, health, inventory feedback
426
+ 9. **Art direction:** one aesthetic, palette, asset family, and UI voice
427
+ 10. **Done looks like:** one observable end-to-end play scenario
311
428
 
312
- The heaviest domains live in on-demand reference modules under [`reference/`](reference/) load one only when you're building in that domain: [`reference/combat.md`](reference/combat.md) (effects, projectiles, death, feel, abilities), [`reference/world.md`](reference/world.md) (terrain, environment, physics, vehicles, spawn), [`reference/multiplayer.md`](reference/multiplayer.md) (transport, host, persistence, presence), [`reference/ui-react.md`](reference/ui-react.md) (`@jgengine/react` hooks, headless + styled UI kits, the registry install path, and the UI quality bar). Each domain's section below is a one-line pointer to its module.
429
+ Keep this compactroughly one line per item. It is a build map, not a large specification or an approval gate. Infer conventional details from the named game or genre. Ask only when two plausible readings would fundamentally change the game.
430
+
431
+ ## Route selectively
432
+
433
+ This skill is the foundation for every task (packages, project shape, defineGame, context, catalogs). After intake, also read only the domain skills the work needs:
434
+
435
+ | Need | Read |
436
+ | --- | --- |
437
+ | Terrain, scenes, camera, movement, physics, maps, sensors | `jgengine-world` |
438
+ | Seeded generation, terrain/environment generation, grids, buildings, simulation | `jgengine-procedural` |
439
+ | Damage, effects, weapons, targeting, projectiles, loot, death | `jgengine-combat` |
440
+ | Items, quests, dialogue, economy, crafting, objectives, turns, social systems | `jgengine-gameplay` |
441
+ | Networking adapters, authority, rooms/topology, persistence/backend seams | `jgengine-multiplayer` |
442
+ | React HUD, shell affordances, controls, feedback, accessibility | `jgengine-ui` |
443
+ | Models, sprites, textures, audio, catalogs, attribution | `jgengine-assets` |
444
+ | Proof and screenshots | `jgengine-verify` after implementation |
445
+
446
+ Do not read every domain by default. Build through documented engine surfaces; do not infer APIs from gallery games. Inspect engine source only when a documented surface appears wrong or a missing primitive blocks the work.
447
+
448
+ ## Build behavior
449
+
450
+ Scaffold with `npx jgengine create game-name --name "Game Name"` when needed. Build the requested game continuously from the intake, keeping systems end-to-end rather than leaving registered-but-unusable pieces. Use real assets and visible feedback early. Verify at completion with `jgengine-verify`.
451
+
452
+ Cartridge-shaped games (declarative config, engine-owned loop): see [reference-cartridge.md](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine/reference-cartridge.md).
453
+
454
+ ---
455
+
456
+ The engine ships **verbs and primitives**; your game ships **nouns** (catalogs) and thin handlers. This skill is that foundation plus intake. Use domain skills only when needed; use `jgengine-verify` afterward. UI guidance lives in [`../jgengine-ui/reference.md`](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-ui/reference.md); assets live in `jgengine-assets`.
457
+
458
+ Load detailed references only for the selected domain: [`jgengine-combat`](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-combat/reference.md), [`jgengine-world`](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-world/reference.md), [`jgengine-multiplayer`](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-multiplayer/reference.md), and [`jgengine-ui`](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-ui/reference.md). Each domain skill explains when its reference is needed.
313
459
 
314
460
  ## Packages
315
461
 
@@ -343,7 +489,7 @@ Exact import paths and export names — **do not invent paths**; every row below
343
489
  |---------|----------------------------------|-----------|
344
490
  | Game boot | `game/defineGame` | `defineGame`, `GameDefinition`, `GameLoop`, `InventoryDeclaration`, `PhysicsConfig`, `GameServerConfig`, `TimeConfig` |
345
491
  | Simulation clock | `time/simClock` | `createSimClock`, `SimClock`, `TimeConfig`, `ClockSnapshot`, `CalendarTime` |
346
- | Runner contract | `game/playableGame` | `PlayableGame`, `GameCameraConfig`, `CameraRigKind`, `TopDownCameraConfig`, `RtsCameraConfig`, `ShoulderCameraConfig`, `LockOnCameraConfig`, `ChaseCameraConfig`, `ObserverCameraConfig`, `CameraShakeConfig`, `CinematicCameraConfig`, `CameraKeyframe`, `EntitySpriteConfig` |
492
+ | Runner contract | `game/playableGame` | `PlayableGame`, `GameCameraConfig`, `CameraRigKind`, `CameraProjection`, `SideScrollCameraConfig`, `TopDownCameraConfig`, `RtsCameraConfig`, `ShoulderCameraConfig`, `LockOnCameraConfig`, `ChaseCameraConfig`, `ObserverCameraConfig`, `CameraShakeConfig`, `CinematicCameraConfig`, `CameraKeyframe`, `EntitySpriteConfig` |
347
493
  | Runtime ctx | `runtime/gameContext` | `createGameContext`, `GameContext`, `GameContextContent`, `GameContextItemEntry`, `GameContextEntityEntry`, `GameContextObjectEntry`, `CatalogEntityRole` |
348
494
  | Behaviour lifecycle | `behaviour/behaviour` | `Behaviour` (`onAwake`→`onEnable`→`onStart`→`onUpdate(dt)`→`onDisable`→`onDestroy`), `BehaviourModule`, `createBehaviourWorld`, `BehaviourWorld`, `JGEngineRegister`, `RegisterField`, `BehaviourModules` — Unity-style lifecycle over an id-keyed node tree (`setActive` cascade, lazy update dispatch); key nodes by entity instance ids. Games augment `JGEngineRegister` via `declare module "@jgengine/core/behaviour/behaviour"` for typed `world.modules`. Three.js binding: `Object3DBehaviour`, `attachObject3D`, `useBehaviourWorld` from `@jgengine/shell/behaviour` |
349
495
  | Reactive keyed store | `store/observableKeyedStore` | `createObservableKeyedStore`, `ObservableKeyedStore` — backs `ctx.game.store` |
@@ -363,6 +509,7 @@ Exact import paths and export names — **do not invent paths**; every row below
363
509
  | Voxel field | `world/voxelField` | `createVoxelField`, `VoxelField`, `VoxelCell`, `VoxelHit`, `VoxelBounds`, `VoxelFieldSummary`, `VoxelFace`, `VOXEL_FACES`, `VOXEL_FACE_NORMALS` — a chunked block lattice, distinct from the `voxel()` `WorldFeature` descriptor |
364
510
  | Terrain field | `world/terrain` | `TerrainField`, `noiseField`, `resolveTerrainField`, `rollingField`, `fractalNoise`, `valueNoise`, `withNormal`, `arenaField`, `flatField`, `resolveGroundStep`, `snapToGround`, `snapEntityToGround`, `resolveTerrainPalette`, `TERRAIN_MATERIAL_PALETTES` |
365
511
  | Seeded RNG | `random/rng` | `seededRng`, `seededStreams` |
512
+ | Seed share link | `random/seedLink` | `withSeedParam`, `seedFromUrl`, `seedFromSearch`, `dailySeed`, `DEFAULT_SEED_PARAM` — encode/decode a world seed to/from a shareable URL query param; `dailySeed` is the UTC daily-run seed |
366
513
  | Name generator | `random/nameGen` | `createNameGenerator`, `pickFrom`, `fillTemplate`, `NameGenerator`, `NameGeneratorOptions`, `SyllableBank` |
367
514
  | Regions | `world/regions` | `createRegionField`, `isRegionField`, `RegionDef`, `RegionField`, `RegionSample` |
368
515
  | Wind field | `world/wind` | `windField`, `WindField`, `WindFieldConfig`, `WindVector` |
@@ -393,6 +540,7 @@ Exact import paths and export names — **do not invent paths**; every row below
393
540
  | Persistence scopes | `runtime/persistenceScope` | `partitionScopes`, `resetRun`, `mergeScopes`, `clearRunFields`, `applyRunReset`, `planScenarioReset`, `ScopeSchema`, `ScenarioReset`, `PersistenceScope` |
394
541
  | Inventory | `inventory/inventoryModel` | `InventoryLayout`, `InventorySet`, `ItemTraits` |
395
542
  | Progression | `game/progression` | `curve`, `evalCurve`, `leveling`, `Curve`, `LevelingTrack`, `LevelProgress` |
543
+ | Talent tree | `game/talents` | `createTalentTree`, `TalentTree`, `TalentTreeConfig`, `TalentNodeDef`, `TalentRequirement`, `TalentAllocateResult`, `ResolvedTalents`, `TalentSnapshot` — point spends gated by prereqs + points-in-branch, resolved once into a cached flat `StatModifierSet` + ability grants |
396
544
  | Inventory slots | `inventory/slotModel` | `createSlots`, `placeAt`, `removeAt`, `moveSlot`, `firstEmpty`, `compactSlots`, `Slot`, `SlotGrid` |
397
545
  | Shaped inventory | `inventory/shapedGrid` | `createShapedGrid`, `placeShaped`, `moveShaped`, `removeShaped`, `canPlace`, `rotateFootprint`, `occupiedCells`, `gridAdjacencyQuery`, `cellFromPoint`, `ShapedGrid`, `Footprint`, `Placement`, `Rotation` |
398
546
  | Card piles | `cards/cardPile` | `createCardPile`, `createCardPileState`, `draw`, `moveCards`, `shuffleZone`, `pileRng`, `CardPile`, `CardPileState`, `CardPileConfig` |
@@ -410,6 +558,7 @@ Exact import paths and export names — **do not invent paths**; every row below
410
558
  | Build permissions | `world/buildPermissions` | `createPlotPermissions`, `createContributionPool`, `PlotPermissions`, `ContributionPool`, `BuildRole`, `ContributionGoal` |
411
559
  | Interiors | `world/interiors` | `createInteriors`, `Interior`, `Exterior`, `SpaceRef` |
412
560
  | Game clock | `time/gameClock` | `getScaledElapsedMs`, `computeGameDay`, `SECONDS_PER_GAME_DAY` |
561
+ | Idle / offline catch-up | `time/idleProgress` | `idleWindow`, `linearCatchUp`, `exponentialCatchUp`, `steppedCatchUp`, `IdleWindow`, `IdleWindowConfig`, `SteppedCatchUpResult` — elapsed-real-time production/growth/decay for a game reopened after being closed |
413
562
  | Scene behaviors | `scene/behaviors` | `wander`, `patrol`, `promptable`, `talkable`, `player` |
414
563
  | Capture check | `scene/captureCheck` | `captureChance`, `rollCapture`, `CaptureCheckInput` |
415
564
  | Owned roster | `scene/roster` | `createRoster`, `Roster`, `RosterEntry`, `RosterCaptureOptions` |
@@ -438,6 +587,7 @@ Exact import paths and export names — **do not invent paths**; every row below
438
587
  | JSON data source | `data/jsonDataSource` | `createJsonDataSource`, `JsonDataSourceOptions` |
439
588
  | Dev proxy routing | `data/devProxy` | `proxiedUrl`, `parseDevProxyTable`, `DevProxyTable`, `ProxiedUrlOptions`, `DEFAULT_DEV_PROXY_PREFIX` |
440
589
  | Grid-cell world rendering | `world/gridInstances` | `resolveGridInstances`, `GridInstanceTransform` |
590
+ | Swarm LOD scheduler | `world/lod` | `createLodScheduler`, `LodScheduler`, `LodSchedulerConfig`, `LodBand` — distance→band index for render detail, `step(id, distance, dt)` throttles per-entity updates by band interval (staggered, accumulates skipped time); pairs with `@jgengine/shell/world/SpriteBatch` for 1000+ entity swarms |
441
591
  | Turn loop | `turn/turnLoop` | `createTurnLoop`, `TurnLoop`, `TurnLoopConfig`, `TurnState`, `PoolConfig`, `PoolState`, `TurnLoopSnapshot` |
442
592
  | Declared-action intent board | `turn/intent` | `createIntentBoard`, `IntentBoard`, `DeclaredIntent` — `declare(participantId, intent)`, `peek`, `all`, `consume`, `clear` |
443
593
  | Commit modes | `turn/commit` | `createCommitController`, `CommitController`, `CommitMode`, `CommitOutcome`, `SubmittedAction` |
@@ -457,8 +607,10 @@ Exact import paths and export names — **do not invent paths**; every row below
457
607
  | Beat clock | `time/beatClock` | `createBeatClock`, `createBeatInputBuffer`, `nextBeatTime`, `BeatClock`, `BeatClockConfig`, `BeatSnapshot`, `BeatInputBuffer`, `BufferedAction` |
458
608
  | Spawn director | `ai/spawnDirector` | `createSpawnDirectorState`, `advanceSpawnDirector`, `advanceWave`, `raiseAlert`, `pickSpawnPoint`, `SpawnDirectorConfig`, `WaveManifest`, `SpawnEntry`, `SpawnRequest`, `DirectorContext` |
459
609
  | Threat table | `ai/threat` | `createThreatTable`, `ThreatTable`, `ThreatTableConfig`, `ThreatEntry`, `HighestThreatOptions` |
610
+ | Group-assist aggro | `ai/groupAssist` | `createAssistNetwork`, `AssistNetwork`, `AssistNetworkConfig`, `AssistMember` — propagates one member's threat gains to same-group members (optional radius + `distanceBetween` gating) so a single pull rallies the group |
460
611
  | Job board | `ai/jobBoard` | `createJobBoard`, `JobBoard`, `JobDef`, `Job`, `JobPhase`, `WorkerState`, `JobReport`, `JobTickContext` |
461
612
  | Crowd flow | `ai/crowd` | `computeFlowField`, `createCrowdField`, `selectPoi`, `FlowField`, `FlowFieldOptions`, `CrowdField`, `Poi`, `SelectPoiOptions` |
613
+ | Factions & reputation | `faction/factions`, `faction/reputation` | `createFactionGraph`, `createFactionRoster`, `FactionRelation`, `FactionDef`, `FactionGraph`, `FactionRoster`, `createReputationLedger`, `DEFAULT_REPUTATION_TIERS`, `tierForStanding`, `effectiveRelation`, `ReputationTier`, `ReputationLedger` |
462
614
  | Physics actors | `physics/ragdoll`, `physics/carryable`, `physics/forceVolume`, `physics/spatialGrid` | `createRagdoll`, `Ragdoll`, `Carryable`, `carrySpeedMultiplier`, `ForceVolume`, `PlatformCarry`, `SpatialGrid` |
463
615
  | Traversal (grapple/glide) | `physics/traversal` | `Grapple`, `GrappleConfig`, `Glide`, `GlideConfig` |
464
616
  | Structural destruction | `physics/structure` | `StructureGraph`, `StructureNodeSpec`, `StructureEdgeSpec`, `StructureMaterial`, `StructureMaterialTable`, `CollapseEvent`, `DebrisConfig` |
@@ -474,6 +626,7 @@ Exact import paths and export names — **do not invent paths**; every row below
474
626
  | Session matchmaking | `multiplayer/matchmaking` | `browseSessions`, `findByJoinCode`, `quickMatch`, `matchesFilter`, `normalizeJoinCode`, `generateJoinCode`, `SessionListing`, `MatchFilter` |
475
627
  | Auth identity | `multiplayer/identity` | `AuthSession`, `PlayerIdentity`, `sessionPlayer`, `resolveGuestSession` |
476
628
  | Text chat | `game/chat`, `multiplayer/chatContract` | `createChat`, `Chat`, `ChatMessage`, `ChatChannelDef`, `whisperChannelId`, `createChatRateLimiter`, `ChatTransport`, `ChatSync`, `createLocalChatTransport` |
629
+ | Chat filter | `game/chatFilter` | `createChatFilter`, `normalizeChatText`, `ChatFilter`, `ChatFilterConfig`, `ChatFilterResult` — mask/reject blocked words (leet-normalized token match); wire via `ChatDeps.filter` (word lists are game data, the engine ships the mechanism) |
477
630
  | Voice seam | `multiplayer/voiceContract` | `VoiceTransport`, `VoiceParticipant`, `VoiceRoute`, `createLocalVoiceTransport`, `createPushToTalk`, `PushToTalkMode` |
478
631
  | Race state | `game/race` | `raceTrack`, `RaceTrack`, `createRaceState`, `RaceState`, `RaceEvent`, `RaceWinCondition`, `firstPastPost`, `topK`, `lastStanding`, `everyoneFinishes` |
479
632
  | Reveal query | `sensor/revealQuery` | `createRevealQuery`, `RevealQuery`, `RevealQueryOptions`, `RevealHit` |
@@ -492,6 +645,8 @@ Exact import paths and export names — **do not invent paths**; every row below
492
645
  | Telegraph | `combat/telegraph` | `pointInTelegraph`, `telegraphProgress`, `telegraphFired`, `telegraphTurnProgress`, `telegraphFiredAtTurn`, `telegraphTurnsRemaining`, `TelegraphShape`, `TelegraphConfig` |
493
646
  | Dash / dodge | `movement/dash` | `createDashState`, `DashState`, `DashConfig`, `DashBurst`, `iframeActive`, `dashOffset` |
494
647
  | Ability kit | `combat/abilityKit` | `createAbilityKit`, `AbilityKit`, `AbilitySlotConfig`, `AbilitySlotSnapshot`, `AbilitySlotState`, `AbilityCastType`, `AbilityCastResult`, `AbilitySlotRetune` |
648
+ | Resource pool | `combat/resourcePool` | `createResourcePool`, `ResourcePool`, `ResourcePoolConfig` — current/max with per-second regen/decay and spend/gain; `pool.current()` is the ability kit's `resourceAvailable` |
649
+ | Combo points | `combat/comboPoints` | `createComboPoints`, `ComboPoints`, `ComboPointsConfig` — discrete points accrued on action, expiring after a timeout from the last gain, spent in bulk |
495
650
  | Event meter | `stats/eventMeter` | `createEventMeter`, `EventMeter`, `EventMeterConfig`, `EventMeterFeedResult` |
496
651
  | Auto-target policy | `scene/autoTarget` | `selectAutoTarget`, `createAutoTargeter`, `AutoTargetPolicy`, `AutoTargeter`, `AutoTargetDeps` |
497
652
  | Resistance matrix | `combat/resistance` | `resolveResistance`, `resistanceScale`, `ResistanceMatrix`, `ResistVerdict`, `ResistanceResult` |
@@ -506,6 +661,15 @@ Exact import paths and export names — **do not invent paths**; every row below
506
661
 
507
662
  ## Getting started (new project)
508
663
 
664
+ Fastest path — the `jgengine` CLI scaffolds the entire canonical shape below (harness, skeleton, stub game, verify test, AGENTS.md) as a booting game:
665
+
666
+ ```sh
667
+ npx jgengine create my-game # then: cd my-game && bun dev
668
+ npx jgengine doctor # later, if the setup drifts (version skew, unstyled HUD, shape strays)
669
+ ```
670
+
671
+ Manual equivalent:
672
+
509
673
  ```sh
510
674
  bun add @jgengine/core @jgengine/react @jgengine/shell react react-dom three three-stdlib @react-three/fiber @react-three/drei
511
675
  bun add -d @tailwindcss/vite tailwindcss # HUD styling (Vite + Tailwind v4)
@@ -780,6 +944,8 @@ Optional render/world fields the shell also reads: `entitySprites` / `entityMode
780
944
 
781
945
  **Lighting and backdrop.** `PlayableGame.lighting` (`LightingConfig`, `@jgengine/core/game/playableGame`) replaces the shell's hardcoded ambient/directional default when present, regardless of world kind: `ambient?: { color?, intensity? }`, `directional?: { color?, intensity?, position, castShadow? }[]`, `hemisphere?: { skyColor?, groundColor?, intensity? }`. `PlayableGame.backdrop` (`BackdropConfig`) is a generic background/sky/fog for **any** world kind, including a custom `environment` component: `background?: string` (CSS color), `sky?: SkyEnvironmentConfig` (same descriptor `environment()`'s `sky` field takes), `fog?: { color?, near?, far?, density? }` (`density` set switches to exponential `FogExp2` and `near`/`far` are ignored). Both are optional and additive to whatever the world/`environment` renderer already draws.
782
946
 
947
+ **Visibility & streaming (automatic).** Every 3D game gets camera frustum + distance culling for free — the shell's `CullingProvider` reads the live camera each frame, runs the engine `createVisibilitySystem` (`@jgengine/core/visibility/visibilitySystem`) over the scene's entities and placed objects, and toggles `group.visible` so off-screen objects are never submitted to the renderer (never unmounted — gameplay and simulation are untouched). Defaults are conservative (a preload margin larger than the view, hysteresis, `Infinity` default render distance) so existing games only benefit. Tune or opt out via `PlayableGame.visibility` (`VisibilityConfig`, `@jgengine/core/visibility/config`): `enabled: false` disables it; `culling`/`streaming` patch the global `CullingSettings`/`StreamingSettings` (`@jgengine/core/visibility/settings`); `scene` sets scene-wide overrides; `entities`/`objects` override by kind name / catalog id (`alwaysVisible`, `maxRenderDistance`, custom `bounds`, `pinned`, `cullingDisabled`, …). The engine layer also ships `@jgengine/core/visibility/spatialIndex` (the 3D hash the culler queries instead of scanning every object), `@jgengine/core/visibility/assetStreaming` (dedup/budget/grace-period asset loading), and `@jgengine/core/visibility/simulationCulling` (opt-in, off by default — throttles low-priority off-screen updates, never protected entities). Full reference: `packages/core/src/visibility/README.md`.
948
+
783
949
  **Player movement tuning** — the `movement` field (`PlayerMovementConfig`) tunes the shell's local-player walk controller beyond `physics.gravity`/`jumpVelocity`: `mode` (`"free"` camera-relative default, `"axis"` locks travel to one world `axis`, `"grid"` snaps each committed step to `cellSize` centers), `collideObjects` (collide against placed scene objects as unit-box AABBs even without `collision.voxel`), and `beforeCommit(frame)` — an escape hatch called each frame with `{ entityId, current, next, dt }` that can return a replacement `[x, y, z]` to constrain or redirect the step (rails, bounds, custom collision) before it commits and before `onTick` runs.
784
950
 
785
951
  The runner boots `createGameContext({ definition, content, player: { userId, isNew } })`, calls `loop.onInit(ctx)` then `loop.onNewPlayer(ctx)`, and drives `loop.onTick(ctx, dt)` per frame. **Convention: `onNewPlayer` spawns the player entity with `id === ctx.player.userId`** — bounded stats, targeting, and kill attribution key off that.
@@ -914,813 +1080,554 @@ export function onTick(ctx: GameContext, dt: number) {
914
1080
  `@jgengine/core/time/beatClock` is a separate, purpose-built signal from `simClock` — a BPM tick generator for rhythm games (Hi-Fi Rush–style quantized combat), not a day/pause clock. `createBeatClock({ bpm, beatsPerBar? }, onBeat?)` returns a `BeatClock`: call `advance(gameDt)` from `onTick` with the same **game-time** `dt` (never wall-clock) — it fires `onBeat(beatIndex)` once per newly crossed integer beat and returns a `BeatSnapshot` (`beat`, `beatIndex`, `bar`, `beatInBar`, `phase`). `createBeatInputBuffer<T>(beatDurationSec)` is the auto-correct input buffer: `buffer(action, nowSec)` quantizes an off-beat press to fire on the next beat tick (or immediately if pressed exactly on one); `advance(nowSec)` drains and returns every action whose beat has arrived. `nextBeatTime(nowSec, beatDurationSec)` is the underlying pure quantization function. Feed a music track's actual BPM in; the buffer is what makes an early/late input still land on-beat.
915
1081
 
916
1082
  ## Content catalogs
917
-
918
- ### Object catalog fields
919
-
920
- | Field | Purpose |
921
- |-------|---------|
922
- | `id`, `model` | Canonical id, asset key |
923
- | `footprint` | `{ w, h, d }` placement bounds |
924
- | `snap` | `"grid"` \| `"free"` \| `"wall"` |
925
- | `solid` | Blocks movement |
926
- | `breakable` | `false` or `{ baseBreakTime, harvest, drops, dropsWhenUnmet }` |
927
- | `proximityPrompt` | Float UI + optional command invoke |
928
- | `slotInventory` | Attached container `{ slots, accepts }` created at place time (`object:<instanceId>`) |
929
-
930
- Break resolution: `duration = baseBreakTime / (tool?.breakSpeed ?? 1)`; drops per `when` (`always` / `harvestMet` / `silkTouch` / `playerKill`); then `inventory.put` + `object.remove`.
931
-
932
- ### Item catalog fields
933
-
934
- | Field | Purpose |
935
- |-------|---------|
936
- | `id`, `kind`, `stack`, `model` | Basics; `stack` feeds `itemTraits.stackLimit` |
937
- | `use` | Game handler name dispatched by `item.use` (`"fireGun"`, `"castBolt"`, `"drinkPotion"`) |
938
- | `weapon` | Stats the handler reads via `item.weapon.getStat` — `damage`, `heal`, `reach`, `manaCost`, `projectile.{mass,gravity,fuseTime,settleOn}`, `explosion.{radius}` … |
939
- | `trade` | `{ buy?: {coins: 80}, sell?, shops?: ["shop_town"] }` |
940
- | `requires` | Unlock ids gating purchase/use |
941
- | `placesObject` | Object id placed from hotbar |
942
- | `rarity`, `baseType` | Read by the `worldItem` rarity render binding + loot filter when this item drops to the ground (#32/#33); `baseType` defaults to the item id when absent |
943
-
944
- ### Entity catalog fields
945
-
946
- | Field | Purpose |
947
- |-------|---------|
948
- | `movement` | `walkSpeed` (reaches spawn automatically), `poses?: ["standing","crouch","prone","running"]`, `aim?: ["hip","ads"]`, `frozen?: boolean` — a scene-instance-level movement lock (cutscenes, stuns, mount transitions); `movedWhileFrozen(entity)` (`scene/entityStore`) flags an entity whose velocity moved anyway despite `frozen: true`, catching a system that bypassed the lock |
949
- | `role` | `CatalogEntityRole` = `"player"` \| `"enemy"` \| `"hostile"` \| `"npc"` \| `"vehicle"` — catalog hostility class for targeting (`"enemy"`/`"hostile"` classify hostile in `cycleTarget`). Distinct from the scene *instance* `EntityRole` (`"player"` \| `"npc"` \| `"prop"`, in `scene/entityStore`) which drives input/camera binding — **possession** (`ctx.player.possession`) flips this instance role between `"player"`/`"npc"` on every control swap, so exactly one owned entity is ever the input/camera target |
950
- | `stats` | Stat declarations — bounded values: `{ health: { max: 120, min: 0 }, level: { max: 60, min: 1, current: 1 }, … }` — `current` optional, defaults to `max` |
951
- | `receive` | Per-effect absorption: `{ damage: { order: ["shield","health"], modifiers? }, heal: { order: ["health"] } }` — keyed by **game-defined effect ids**; presence = can receive |
952
- | `onDeath` | `{ drops: "table_id" }` or reason-aware `{ drops: [{ table, when: { reason: "player_kill" } }], command?: { name, when? } }` |
953
- | `wander`, `talkable` | AI descriptor; dialogue id sugar for a talk prompt |
954
-
955
- ### Dialogue catalog
956
-
957
- `entities/npcs/dialogues.ts` — `{ id, lines: [{ speaker, text } | { choices: [{ label, invoke: { command, args } | null }] }] }`. Choices invoke `quest.accept`, `trade.open`, etc. Types ship from `@jgengine/react/components` (`DialogueDef`, `DialogueChoice`, `DialogueLine`) so a game imports them rather than redeclaring — the `DialogueBox` component renders the same shape it types.
958
-
959
- A choice may gate its branch behind a roll: `{ check: { modifier, dc, advantage? }, onSuccess?, onFailure? }` (`onSuccess`/`onFailure` default to `invoke` when omitted). `DialogueBox` rolls via `@jgengine/core/stats/rollCheck`'s `rollCheck({ modifier, dc, advantage }, rng?)` (d20 by default; `advantage`/`disadvantage` roll twice and take the high/low; a natural 1 or max-die result reports `critical`) when the player clicks a checked choice, then calls `onChoice(choice, result)`; game code resolves which command to run with `resolveDialogueInvoke(choice, result)` (also exported from `@jgengine/react/components`).
960
-
961
- ## `scene.entity.stats` — bounded stats
962
-
963
- ```ts
964
- stats.get(instanceId, statId) // → { current, max, min } | null
965
- stats.set(instanceId, statId, { current?, max?, min? })
966
- stats.delta(instanceId, statId, n) // → null | { reason } — clamps into [min, max]
967
- ```
968
-
969
- Health, mana, xp, level, energy — any stat id declared on the catalog. Spawn seeds from the catalog (`current ?? max`). Combat writes through effects; non-combat (regen ticks, XP grants) calls `delta` directly.
970
-
971
- **XP/level use the engine progression primitive.** `@jgengine/core/game/progression` ships `curve()`/`evalCurve()` (evaluate a game-owned XP-per-level curve *definition*) and `leveling()` (a level track over the bounded `xp`/`level` stats that reports overflow). You own the curve *numbers* in a catalog; the engine owns the overflow math — on level-up bump `level.current`, reset `xp.max` from the curve, push a `stat.levelUp` feed entry. Hand-rolling `xpForLevel`/`levelFromXp`/`xpToNextLevel` is the anti-pattern — those already exist. `LevelingConfig.thresholdMode` picks how the curve is read: `"perLevel"` (default) treats `xpForLevel(N)` as the incremental N-1→N cost, summed internally; `"cumulative"` treats `xpForLevel(N)` as the total lifetime XP to reach level N (0 at/below `startLevel`) and compares `xp.current` straight against those totals — pick this for a design that quotes "total XP to level N" tables.
972
-
973
- `leveling({ …, thresholdMode: "cumulative" })` switches to lifetime-total semantics: `xp` is a running total instead of a per-level bank, and `resolve(level, xp)` walks upward from `level` to the highest level whose cumulative threshold `xp` clears — it never demotes, and clamps the returned `xp` to the max-level threshold once capped. Default is `"perLevel"` (unchanged, fully back-compat) — pick `"cumulative"` for a total-XP display (MMO-style "12,450 XP") instead of a resettable per-level bank.
974
-
975
- `ctx.player.stats` is a different thing: **modifiers** (buffs, ADS zoom, walk-speed bonuses) via `base/add/remove/get` with expiries — never bounded current/max values.
976
-
977
- ## Targeting (MMO tab-target)
978
-
979
- Persistent per-entity session state — never a per-use input field.
1083
+ ## `ctx.game.store` — reactive game state
980
1084
 
981
1085
  ```ts
982
- ctx.scene.entity.setTarget(fromId, toId | null)
983
- ctx.scene.entity.getTarget(fromId) // instanceId | null
984
- ctx.scene.entity.cycleTarget(fromId, { filter: "hostile" | "friendly" | "any", direction? })
1086
+ ctx.game.store.set("health", 100) // any key, any value type
1087
+ ctx.game.store.get("health") // T | undefined
1088
+ ctx.game.store.has("health")
1089
+ ctx.game.store.delete("health")
1090
+ ctx.game.store.subscribe(listener) // change-signal fires on set/delete
1091
+ ctx.game.store.mapSnapshot() / arraySnapshot()
985
1092
  ```
986
1093
 
987
- Hostility comes from catalog `role` (`"enemy"`/`"hostile"` classify hostile). Input `tabTarget`/`clearTarget` actions route here. Handlers always read `getTarget(input.from)` `ItemUseInput` deliberately has **no `to` field** (single source of truth, no client-supplied target to validate, three targeting models stay clean: aim for shooters, `queryArc` for melee, `getTarget` for MMO).
988
-
989
- ## `item.use` — one verb for all usable items
990
-
991
- ```ts
992
- ctx.item.use.register(handlers) // once in onInit; duplicate names throw
993
- ctx.item.use.can(ctx, input) // → { reason } | null
994
- ctx.item.use.use(ctx, input) // dispatches catalog `use` → your handler
995
-
996
- type ItemUseInput = { from: string; itemId: string; inventoryId?: string; aim?: Aim };
997
- type ItemUseHandler<GameContext> = {
998
- can?(ctx, input): { reason: string } | null;
999
- apply(ctx, input): { state: GameContext; error?: string };
1000
- };
1001
- ```
1094
+ A reactive per-game keyed store (`ObservableKeyedStore<unknown>`) attached to `GameContext` reach for it instead of a module-level singleton store for ad-hoc reactive game state (turn trackers, deck UIs, anything that doesn't already have a `ctx` surface). `set`/`delete` bump `ctx.version()` and notify `ctx.subscribe` listeners; `get`/`has` are plain reads. Unlike a per-slot handle, there is no `define`/seed step a key simply doesn't exist until the first `set`.
1002
1095
 
1003
- **Handlers receive the full `GameContext` as state** and mutate through it. Handlers own ammo, cooldowns, range checks, and effect ids; the engine owns projectile geometry, stat clamp math, and `canReceive`.
1096
+ ## `ctx.game.cards` / `ctx.game.turn` lazily-created piles and turn loops
1004
1097
 
1005
- | Handler | Engine calls |
1006
- |---------|--------------|
1007
- | gun | spend ammo → `fireProjectile` → `settleProjectile` |
1008
- | grenade | `fireProjectile` (ballistic) → settle → `effect({ at, radius })` |
1009
- | melee | `queryArc` + reach from `getStat` → `effect` per hit |
1010
- | MMO cast | `getTarget(from)` → `stats.delta(mana)` → `effect({ to })` |
1011
- | consumable | `effect({ to: from, effect: "heal", via: { amount: -n } })` |
1098
+ `ctx.game.cards.pile(id, config?)` and `ctx.game.turn.loop(id, config?)` lazily create (config required on first call) or return the existing notify-wrapped `CardPile`/`TurnLoop` for `id` — call with just the id after the first `onInit` seed to fetch the same instance; every mutating method is wrapped so it bumps `ctx.version()`/notifies `ctx.subscribe` automatically, same as every other `ctx` surface. This replaces manually constructing `createCardPile`/`createTurnLoop` and wiring notification yourself.
1012
1099
 
1013
- Banned in the engine: `weapon.fire`, `consumable.use`, `game.combat.*`, per-weapon commands.
1100
+ ## Movement, pose, input
1101
+ ## External data — `data/dataSource` and the dev proxy
1014
1102
 
1015
- ### Skill-checks and QTE (timed/rolled minigames)
1103
+ Renderer-free async-state primitives (`@jgengine/core/data`) for a game that reads a live external source (a leaderboard API, a session browser, remote config) — distinct from `ctx.game.store`/multiplayer, which are for the game's own authoritative state.
1016
1104
 
1017
- `@jgengine/core/interaction/skillCheck` models a moving-target-zone minigame (casting/reeling, active-reload): `evaluateSkillCheck({ trackWidth, zone, markerPeriod, window, zoneDriftPerSecond? }, elapsedSeconds)` bounces a marker back and forth over `markerPeriod` seconds and returns `{ success, timedOut, markerPosition, zone }` `zone` itself can drift when `zoneDriftPerSecond` is set. It is pure: an `item.use` handler starts a session by recording `ctx.time.now()` (game-time, so pause/fast-forward apply for free) the first time it's pressed, and evaluates `evaluateSkillCheck` against the elapsed time on the next press to lock in success/fail the session bookkeeping (a `Map<instanceId, startedAt>`) is game-owned, same pattern as an ability-cooldown map.
1105
+ - **`createDataSource(load, options?)`** (`data/dataSource`) `DataSource<T>` wraps one `load(signal)` async call as `{ status: "idle"|"loading"|"ready"|"error", data, error }`. `getState()` reads the current snapshot, `subscribe(listener)` fires on every change, `refresh({ force? })` re-runs `load` (de-duplicates a call already in flight unless `force`; aborts the prior call first when forced), `startPolling(intervalMs?)`/`stopPolling()` run `refresh` on an interval (`intervalMs` falls back to the one passed at construction; throws if neither is given), `dispose()` tears down polling and in-flight requests for good. Pass `options.clock` (`{ setInterval, clearInterval }`) to swap the timer source in tests.
1106
+ - **`fetchJson<T>(url, options?)`** (`data/fetchJson`) — `fetch` + JSON-parse in one call; throws `HttpStatusError` (`status`, `statusText`, `url`) on a non-OK response and `JsonParseError` (`url`, `cause`) on unparsable JSON, so a `DataSource`'s `error` is always one of these two typed shapes, never a bare `Error`. `options.fetchImpl` swaps the fetch implementation for tests/SSR.
1107
+ - **`createJsonDataSource<T>(url, options?)`** (`data/jsonDataSource`) — sugar combining the two above: a `DataSource<T>` whose `load` calls `fetchJson(url, options)`.
1108
+ - **Dev proxy (`data/devProxy`)** — same-origin routing for external APIs during `bun dev` so browser CORS never blocks a game's `fetchJson` call against a third-party host. `parseDevProxyTable(raw)` parses a `VITE_JGENGINE_DEV_PROXY` env value (a JSON object of `{ routeName: "https://api.example.com" }`) into a `DevProxyTable`; `proxiedUrl(target, { dev?, table?, prefix? })` rewrites a `target` URL whose prefix matches a table entry into `/proxy/<routeName>/<rest>` (default prefix `/proxy`) when `dev` is true (defaults to `import.meta.env.DEV`) — else returns `target` unchanged, so the same call hits the real host in production. `apps/dev`'s `vite.config.ts` reads the same env var and wires a matching Vite server `proxy` entry per route (`changeOrigin: true`, strips the `/proxy/<routeName>` prefix) — set `VITE_JGENGINE_DEV_PROXY` once and both sides (the URL rewrite and the actual proxy route) agree.
1018
1109
 
1019
- `@jgengine/core/interaction/qte` sequences discrete timed prompts: `evaluateQteSequence(steps: QteStep[], inputs: QteInputEvent[])` walks `{ id, action, windowStart, windowEnd }` steps against `{ action, at }` presses and returns `{ status: "success" }` or `{ status: "fail", atStep, reason }`; `pendingQteStep`/`qteProgress` read the currently-active step and fraction complete for UI.
1110
+ ## Multiplayer and the backend seam
1111
+ ## Genre cheat sheet
1020
1112
 
1021
- `@jgengine/react` ships matching headless UI: `SkillCheckBar({ config, startedAt })` and `QteTrack({ steps, startedAt })` self-tick via `requestAnimationFrame` and read `ctx.time.now()` each frame — pass `className`/`trackClassName`/`zoneClassName`/`markerClassName` (or `stepClassName`/`activeClassName`/`doneClassName` for `QteTrack`) for the moving-zone/timing visuals the UI quality bar requires.
1113
+ - **Voxel/crafting**: objects for blocks/machines, `voxel()`, `object.break`/`object.placeFromInventory`.
1114
+ - **Tycoon/lab**: objects + `slotInventory`, `plots()`, configure via prompt → command.
1115
+ - **Shooter**: `fireProjectile`/`settleProjectile`; grenades settle → `effect({ at, radius })`; `movement.poses`/`aim` + zoom modifier; `servers({ … })` + game-owned `server.mode`; loadout classes from commands.
1116
+ - **MMO/RPG**: bounded stats + `leveling()` over a game XP curve; `tabTarget` → `cycleTarget`; handlers read `getTarget`; quests bound to `entity.died`/`inventory.added`; social party + `partyShare`; `server: "persistent"`.
1117
+ - **All combat games**: react to `entity.died` (feed/leaderboard/score) — never poll HP.
1022
1118
 
1023
- ### Capture and owned roster
1119
+ ## Anti-patterns
1024
1120
 
1025
- `@jgengine/core/scene/captureCheck` `captureChance({ hpFraction, catchPower, difficulty? })` returns a 0..1 probability (lower `hpFraction` and higher `catchPower` raise it, higher `difficulty` lowers it); `rollCapture(input, rng?)` rolls it. `@jgengine/core/scene/roster` — `createRoster()` is a persisted, per-owner store (`capture`, `release`, `list`, `get`, `setEquipped`, `equippedList`, `snapshot`/`hydrate`) wired onto the runtime as `ctx.game.roster`, distinct from `game.social.party` (session-ephemeral) — roster entries persist and are optionally equipped (deployed) independent of party membership.
1121
+ | Wrong | Right |
1122
+ |-------|-------|
1123
+ | Player tuning in `defineGame` | Entity catalog `movement` + stats |
1124
+ | `behaviors: […]` on place/spawn | Catalog entry |
1125
+ | Engine `weapon.fire` / `consumable.use` / `combat.*` | `item.use` + catalog `use` → game handler |
1126
+ | `ItemUseInput.to` for targets | `getTarget(from)` in handlers |
1127
+ | `effect({ to })` for gunshots | `fireProjectile` + `settleProjectile` |
1128
+ | Polling HP in `onTick` for kills | `entity.died` event |
1129
+ | `combat.lootTable` / `loot.enemy` | `onDeath` on the entity that died |
1130
+ | Hand-rolled `Math.random()` loot in commands | `lootTable()` + `ctx.game.loot.roll` |
1131
+ | Hand-rolled `xpForLevel`/`levelFromXp` | `game/progression` `curve()` + `leveling()` |
1132
+ | Hardcoded shop arrays | `item.trade.shops` + `tradableAt` |
1133
+ | Kit seeding via scattered `put`/`grant` | `applyLoadout` |
1134
+ | Per-user quest state hand-rolled | `game.quest.register` + binds |
1135
+ | `useKillFeed` / per-domain feed hooks | `useFeed({ action })` |
1136
+ | Raw keys in game logic | `defineGame` input actions |
1137
+ | Positioning inside `ui/components/` or on primitives (`CurrencyPill className="absolute …"`) | Screen wrappers in `GameUI.tsx` only |
1138
+ | Game UI classes without `@source` in host CSS | `@source` entries for your game dirs + `node_modules/@jgengine/{shell,react}` |
1139
+ | One file per catalog entry / per brand | Dense `<domain>/catalog.ts` |
1140
+ | Convex mutations called from game code | `commands.run` through the `GameBackend` transport |
1141
+ | Half a system: quest without tracker, cooldown without sweep, keybind never shown, stub "coming soon" modal | Finish the system end to end — or cut it whole (see `jgengine`) |
1142
+ | Game-side workaround for a missing engine primitive | File the gap at github.com/Noisemaker111/jgengine/issues (or PR the primitive) and cut or scope the dependent system honestly |
1143
+ | Game nouns in this skill | Engine primitives + placeholder ids only |
1026
1144
 
1027
- A capture item's `item.use` handler composes the primitives instead of forking them: read the wild target's hp via `ctx.scene.entity.stats.get(target, "health")`, roll `rollCapture({ hpFraction, catchPower })`, and on success call `ctx.scene.entity.despawn(target)` + `ctx.game.roster.capture(ownerId, catalogId)` — the wild scene entity is removed and re-parented into the owner's persisted roster; the react `CaptureOdds({ chance })` component shows the live odds meter the UI quality bar requires.
1145
+ ## New-game definition of done
1028
1146
 
1029
- ## Combat effects, projectiles, death, feel, abilities
1147
+ This is a gate, not a suggestion — every box, in one pass (workflow: **`jgengine`** skill). "Compiles and the hooks are wired" is not done; a declared system with no UI, no feedback, or no way to exercise it is not done — finish the system or cut it whole.
1030
1148
 
1031
- Combat primitives effects & projectiles, death handling, melee/defense/telegraph feel, and abilities/resources/auto-target/resistance/run drafts. Full surface: **[reference/combat.md](reference/combat.md)**.
1149
+ - [ ] `game.config.ts` (`defineGame` from `@jgengine/shell/defineGame`) + `index.tsx` (barrel) + `main.tsx` (standalone host) + `loop.ts` + `game/content.ts`
1150
+ - [ ] Catalogs: `game/entities/<role>/catalog.ts`, `game/items/<domain>/catalog.ts`, `game/objects/catalog.ts`; loot tables beside their domain
1151
+ - [ ] Entity `stats` + `receive` orders aligned on the same stat ids; `role` set (drives targeting + camera)
1152
+ - [ ] `game/items/use-handlers.ts` registered in `onInit`; handlers read `getTarget`/`aim`, never a target input
1153
+ - [ ] `game/loadouts.ts` + `applyLoadout` in `onNewPlayer` (gated on `isNew`)
1154
+ - [ ] `game/quests/catalog.ts` + binds; if using xp/level, a game-owned curve fed to `game/progression` (`curve`/`leveling`) — **with their HUD/tracker, or cut**
1155
+ - [ ] `onInit`: register handlers/loadouts/loot/quests, event listeners, feed binds, leaderboard tracks; `setupWorld`
1156
+ - [ ] Player spawns with `id === ctx.player.userId`
1157
+ - [ ] `game/ui/GameUI.tsx` owns layout; components use `@jgengine/react` hooks
1158
+ - [ ] UI passes the **quality bar** above (contrast, scale, framing, genre fit) — not just hook wiring
1159
+ - [ ] Camera tuned via `camera` in `defineGame({...})` — defaults untouched means the feel was never checked
1160
+ - [ ] For an `environment()` world: a `<game>.world.test.ts` asserts `summarizeEnvironment(world)` (`@jgengine/core/world/environmentSummary`) is non-empty with the expected counts — the browserless scene-correctness gate
1161
+ - [ ] HUD screenshotted over a staged `GameUiPreview` scenario and **judged by looking at the image** against the UI quality bar in [`../jgengine-ui/reference.md`](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-ui/reference.md) — the final human glance, not the verification loop
1162
+ - [ ] Co-located bun tests for pure game math (curves, cooldowns, spawn logic)
1163
+ - [ ] Multiplayer via adapter config only; no direct backend calls
1032
1164
 
1033
- ## Loot
1165
+ ## Quick reference
1034
1166
 
1035
- ```ts
1036
- lootTable({ id, rolls?, entries: [{ item? | currency?, count: n | [min,max], weight }] })
1037
- ctx.game.loot.register(table) // in onInit
1038
- ctx.game.loot.has(id) / roll(id, rng?) / grantToPlayer(userId, drops, source?)
1039
1167
  ```
1168
+ defineGame (shell) engine fields (assets, world, physics, inventories, input, server, save, time, feed, multiplayer)
1169
+ + presentation fields (content, loop, GameUI, camera, environment, shadows, movement, devtools, …) in one call — smart defaults fill the rest
1170
+ defineGame (core) the underlying engine-only primitive: assets, world, physics, inventories, input, server, save, time, feed, multiplayer, loop
1171
+ PlayableGame { game, content, loop, GameUI, camera, … } — the runner contract `defineGame` (shell) returns
1172
+ GameContext ctx.scene / ctx.game / ctx.player / ctx.item / ctx.camera / ctx.input + subscribe/version
1173
+ scene.object place, remove, move, rotate, at, setVisual (per-instance ObjectVisual: scale/color/opacity override)
1174
+ scene.entity spawn (anchor/offset), despawn, setPose, update; stats; targeting; effects;
1040
1175
 
1041
- Tables colocate with their domain (`entities/enemies/loot-tables.ts`, `objects/loot-tables.ts`). Entities reference them via `onDeath.drops`; chests via a `loot.open` command arg. `grantToPlayer` fills declared inventories, grants currencies, and emits `loot.granted`.
1176
+ # jgengine-ui
1042
1177
 
1043
- ## Card, board & shaped-inventory primitives
1044
- Pure, renderer-free structures for card, board, and deckbuilder games — they sit **beside** the slot inventory, not in place of it. All are immutable-reducer + thin-controller pairs, mirroring the two-tier ctx/factory model: use the `create*` controller in game code, reach for the exported pure functions (`draw`, `moveCards`, `tickTimeline`, `laneAggregate`, `runPipeline`, `placeShaped`) for unit tests and headless servers.
1045
- ```ts
1046
- // cards/cardPile — named ordered zones (deck/hand/discard/exhaust); seeded shuffle, hand limit, reshuffle-on-empty
1047
- const pile = ctx.game.cards.pile("deck", { zones: ["deck","hand","discard","exhaust"], drawFrom:"deck", handZone:"hand", discardTo:"discard", handLimit:7, reshuffleFrom:"discard" });
1048
- pile.reset(createCardPileState(pileConfig, { deck: ids })); // seed zone contents once, from onInit
1049
- pile.shuffle("deck", seed); // seeded Fisher–Yates via pileRng — deterministic under the same seed
1050
- pile.draw(5); // deck → hand, clamped to handLimit, reshuffles discard when deck runs dry
1051
- pile.discard(ids); pile.exhaust(ids, "exhaust"); // Slay the Spire / Balatro lifecycle
1052
- // cards/modifierPipeline — ordered { source, apply(value) → value } with an inspectable per-step trace
1053
- const score = runPipeline({ chips: 10, mult: 1 }, jokers); // score.value + score.trace[i].{before,after,changed} for Balatro-style scoring readouts
1054
- // board/laneBoard — N lanes, per-side power aggregate + optional per-lane LaneRule modifier (Marvel Snap / Inscryption)
1055
- board.aggregate(lane, "player").total; board.outcome(lane).winner; board.lanesWon();
1056
- // board/timelineBoard — N slots each on an independent cooldown, resolving in expiry order (The Bazaar auto-battlers)
1057
- board.tick(dtMs); // → fires[] sorted by expiry time then slot index; multiple fires per slot per tick
1058
- // inventory/shapedGrid — polyomino footprints, rotate, overlap-check, adjacency (Backpack Hero / Tetris inventory)
1059
- placeShaped(grid, { id, value, footprint }, [col,row], rotation); // rotateFootprint / canPlace guard overlap + bounds
1060
- gridAdjacencyQuery(grid).neighborsOf(id); // feeds synergy effects
1061
- ```
1062
- Reuse the engine's seeded RNG (`pileRng`) for anything random — never `Math.random()` in game logic. The React drag/rotate/drop/snap gesture layer over these lives in `@jgengine/react` (see UI section).
1063
-
1064
- `ctx.game.cards.pile(id, config?)` is the runtime-wired accessor for `createCardPile`: lazily creates the pile on first call (`config` required then) or returns the existing one for `id` on every later call, and every mutation notifies `ctx.subscribe`/bumps `ctx.version()` — so a `useEngineState`-bound hand/discard view re-renders without a game-owned store. Reach for `createCardPile` directly only for a headless test or server; game code goes through `ctx.game.cards.pile`.
1065
-
1066
- ## Puzzle primitives — cell grids and falling pieces
1067
-
1068
- Two pure, renderer-free `@jgengine/core` primitives for cell-based puzzle games (Tetris wells, match-3 boards); tile art and the drop-cadence loop are the shell's/game's job.
1069
-
1070
- - **`puzzle/cellGrid`** — a generic immutable `CellGrid<T>` for uniform typed-cell boards. Row 0 is the top; `y` grows downward. `createCellGrid`, `cellAt`, `withCell`/`withCells` (immutable single/batch writes), `fullRows`/`clearRows` (line-clear + compaction), `collapseColumns` (match-3 cascade gravity), `findRuns` (run detection with an optional custom matcher).
1071
- - **`puzzle/fallingPiece`** — the falling-piece layer over a `CellGrid`: `ShapeTable<TShape>` maps rotation states to cell offsets; `pieceCells`/`pieceCollides`/`mergePiece` place, test, and commit a piece; `dropDistance` computes the ghost-piece landing row; `gravityInterval`/`levelForLines`/`lineScore` are the classic Tetris drop-speed/level/score curves (overridable); `createLockDelay`/`stepLockDelay` is the grounded→countdown→lock stepper (`delaySeconds: 0` locks instantly on touchdown).
1072
- - **`tactics/fallingGrid`** — a generic tile-drop grid over any `TCell` payload (distinct from the `cellGrid`/`fallingPiece` row-clear pair): `createFallingGrid(config)`, `gravityIntervalMs(level, config?)` for the drop-speed curve, and a `FallingGridSnapshot`/`LockState` shape for the grounded→lock stepper.
1073
-
1074
- ## Dropped items — `worldItem` and the loot filter
1075
- A `worldItem` is a scene **entity** (position + item ref + rarity), never an inventory item or object — see the three buckets. `onDeath.dropMode: "world"` (above) is the usual producer; games can also hand-place ground loot (chests, quest drops).
1076
- ctx.scene.worldItem.spawn({ itemId, position, rarity?, baseType?, count?, affixTier?, source? })
1077
- ctx.scene.worldItem.get(instanceId) / list() / nearestInRadius(from, radius, filter?)
1078
- ctx.scene.worldItem.pickup(instanceId, userId) // grants to inventory + despawns, emits worldItem.picked_up
1079
- Click-to-grab is engine-owned: setting `pointer.grabWorldItems: true` in `defineGame({...})` makes `@jgengine/shell`'s `GamePlayerShell` resolve `pointer.worldHit()` on primary click, and — when the hit entity is a `worldItem` within the `worldItem.pickupRadius` (default `DEFAULT_PICKUP_RADIUS`) configured on `defineGame({...})` of the local player — calls `pickup` directly, no game command needed. `@jgengine/react`'s `useWorldItems()` / `useNearestWorldItem(radius)` drive a HUD pickup prompt off the same store.
1080
- Presentation is a two-layer render binding, both engine-owned (rendered by `@jgengine/shell`'s `WorldItems`) over **game-supplied data**:
1081
- 1. **Rarity baseline** — the `worldItem.rarityStyle: Record<rarity, { color?, beam?, label? }>` field of `defineGame({...})`, the game's rarity palette (Borderlands/Diablo-style beam + color coding).
1082
- 2. **Loot filter overlay** (#33) — the `worldItem.filter: LootFilterRule[]` field of `defineGame({...})`, built with `lootFilter([{ id, when: { rarity?, baseType?, minAffixTier?, maxAffixTier? }, hide?, color?, beam?, label? }])` from `game/lootFilter`. **First matching rule wins** (PoE/Last Epoch block semantics); a rule only overrides the fields it sets, everything else falls back to the rarity baseline. `resolveWorldItemPresentation(item, rarityStyle, rules)` composes both layers and is what the shell calls per item.
1083
- ## Gear systems — durability, affixes, modular items, storage tiers
1084
- Four pure primitives that hang off item **instances** (not the stackable catalog id) — all catalog-first (specs are game-supplied config) and renderer-free. Item instances that carry durability/affix/modular state key off a game-assigned instance id, the same way targeting keys off entity instance ids.
1085
- **Durability** (`item/durability`) — per-instance wear + repair. `DurabilitySpec` (`{ max, wearPerUse?, wearPerHit?, disableAtZero?, repair? }`) is catalog data; `createDurability(spec)` seeds a `DurabilityState`, `wear(spec, state, "use" | "hit", times?)` decrements (floors at 0), `isDisabled(spec, state)` gates use at zero, `durabilityFraction` feeds a HUD bar. Repair is quote-then-apply: `repairQuote(spec, state, { station?, to? })` returns the `{ item, count }[]` material cost (scaled by points restored) + the post-repair state (optional `qualityLossPerRepair` shrinks `max` each repair, Tarkov-style) — the game charges the materials through inventory, then commits the quote's `state`. `createDurabilityTracker()` keeps `DurabilityState` per instance id for the runtime.
1086
- **Affix roller** (`item/affix`) — procgen `base × rarity → { rolled affixes, computed stats, name }`. `createAffixRoller({ pools, rarities })` over rarity-weighted `AffixPool`s. `roll(base, rarityId, rng)` draws `affixCount` distinct affixes without replacement (weighted, via the engine's `pickWeighted`), computes stats (base × `rarity.statScale`, then `op: "add"` affixes, then `op: "mul"`), and composes a name from `rarity.namePart` + prefix/suffix parts. `rollRarity(rng)` picks a weighted tier; `rollRandom(base, rng)` chains both. Pass `seededRng(seed)` for deterministic drops; any `() => number` rng works (same contract as `loot.roll`). `seededRng` lives in `random/rng` (re-exported here) alongside `seededStreams(seed)`, which derives independent named streams from one seed — `streams("worldgen")` vs `streams("history")` — so simulation draws never perturb generation (intervening in a run cannot change the map).
1087
- **Modular item** (`item/modularItem`) — a whole assembled from parts in typed mount slots (guns, mechs). `ModularItemDef` has `slots: MountSlotDef[]` (`{ id, accepts, required? }`); `install(def, installed, slotId, part)` validates the slot exists, accepts the part's `category`, and is empty; `computeEffectiveStats(def, installed)` rolls part `stats` (additive) then `multipliers` over `baseStats`; `missingRequiredSlots`/`isComplete` gate a buildable whole. `createModularItem(def)` is the stateful wrapper (`install`/`uninstall`/`effectiveStats`/`partInSlot`).
1088
- **Storage tiers + insurance** (`inventory/storageTier`) — the extraction-economy inventory half. Inventory containers carry a `tier: "carried" | "banked"` (`InventoryDeclaration.tier`; a Tarkov secure container is just a `banked` container on the body). `partitionOnDeath(containers)` splits a death snapshot into `{ kept, lost }` (banked survives, carried is dropped, stacks merged). `createDeliveryQueue()` is the delayed-delivery (insurance) hook: `schedule` a `ScheduledDelivery` with a game-time `deliverAt`, then `due(now)` / `claimDue(now)` drain it on the tick clock. `insureLost(lost, policy, userId, now, rng?)` filters the lost set to insured items and stamps a delayed `deliverAt` → feed straight into the queue. `resolveConsolation(policy, partition)` returns a baseline loadout id (apply via `applyLoadout`) — the death consolation grant, optionally gated on `if-carried-empty`. *(Session/round machines — extraction hold-to-leave, raid banking — consume this tier; see the objective-machine group.)*
1089
- ## Objective, round & session machines
1090
- Content-agnostic state machines for competitive/session shapes — plant/defuse, buy/live/end rounds, downed/revive, the battle-royale ring, extraction raids, run-vs-meta persistence. All pure `core`; every timer takes a **game-time** `dt`/`now` (`ctx.time`), so pause and fast-forward apply for free. Drive them from `loop.onTick` and pipe their events into `ctx.game.feed`/`events`; render their snapshots as HUD (per the UI quality bar in [`reference/ui-react.md`](reference/ui-react.md) — the downed banner, ring warning, and extraction timer are required HUD).
1091
- **Contested channel** (`session/contestedChannel`) — the interrupt-on-damage progress objective behind plant/defuse, cash-out, urn deposit, banishing, and hold-to-extract. `createContestedChannel({ duration, interruptOnDamage?, resetOnInterrupt?, favorability?, ratePerOccupant?, contested?, decayRate? })`: `start(team)` begins the channel, `tick(dt, occupants)` advances it against per-team occupancy (`Record<teamId, count>`) and emits `start`/`tick`/`contested`/`paused`/`complete` events, `damage(reason?)` interrupts (keeps or zeroes progress per `resetOnInterrupt`). `favorability[team]` scales fill rate (Deadlock deposit); `ratePerOccupant` fills faster with more owners present; `contested: "pause" | "decay"` chooses whether an opposing occupant freezes or reverses progress (The Finals contest). The owner leaving pauses it. Extraction hold-to-leave reuses this primitive verbatim.
1092
- **Round state** (`session/roundState`) — the buy→live→end match machine (Valorant/CS). `createRoundState({ phases, teams, phaseOrder?, winCondition?, maxRounds?, winReward?, lossBonus? })`: `tick(dt)` runs the phase timer and auto-advances (emitting `phase.start`/`phase.end`, rolling the last phase back into the next round's first), `concludeRound(winner)` records the win on any "conclude-eligible" phase (any phase but the first/last in the cycle), settles `round.economy` (winner gets `winReward`, losers get an escalating `lossBonus` via `lossBonusFor(rule, streak)` clamped to `max`), and moves to the next phase. `onPhaseEnd(hook)` fires commerce/spawn gates on each transition; `match.end` fires at `maxRounds`. `server.mode` stays a game string — this is the timer/economy engine under it.
1093
-
1094
- Two extras beyond the default buy/live/end cycle: `phaseOrder?: string[]` overrides the phase names/cycle entirely (a wider `Record<string, number>` `phases` shape to match) — a draft→ban→play→score cycle is the same machine with different phase names. `teams: (string | { id, role? })[]` accepts a plain id or a `{ id, role }` pair; `roleOf(team)` reads the tag back (`"attacker"`/`"defender"`, Valorant side assignment) without a parallel lookup table. `winCondition?: (snapshot: RoundSnapshot) => string | null` lets `evaluate()` (call it from `onTick` alongside `tick(dt)`) auto-conclude the round the instant a score/objective condition is met, instead of the game hand-calling `concludeRound` — return a team id to end it, `null` to keep playing; `RoundSnapshot` is `{ round, phase, timeLeft, scores, lossStreaks, roles, matchOver }`.
1095
- **Role assignment** (`session/roles`) — `assignRoles(players, specs: RoleSpec[])` distributes fixed-count or proportional roles (hider/seeker, spy/operative, prop/hunter) across a player list — the allocation half of an asymmetric session mode; `RoundConfig.teams`' per-team `role` is the lighter-weight alternative when a round machine already tracks the roster.
1096
- **Downed / revive** (`combat/downed`) — the 3-state alive→downed→dead chain (Apex/Helldivers). `createDownedState({ bleedoutSeconds, reviveSeconds?, reviveHealthFraction?, banner? })`: `down(id)` starts the bleedout, `tick(dt)` counts it down (→ `died`, optionally spawning a `banner`), `revive(id, dt)` accumulates an ally's hold time (→ `revived` with the health fraction the game restores), `finish(id)` executes a downed enemy, and `respawnFromBanner(id)` brings a banner-holder back at a beacon. It sits **in front of** the engine death resolution: on lethal damage call `down` instead of dying; on `died`/`bleedout` run the real `resolveDeath`. No banner ⇒ death is terminal.
1097
- **Shrinking ring** (`session/ring`) — the battle-royale safe zone with out-of-bounds DoT. A catalog `RingConfig` is `{ center, phases: RingPhase[] }` where each phase is `{ startTime, shrinkDuration, fromRadius, toRadius, damagePerSecond, center? }` on the game clock. `ringSampleAt(config, t)` / `createRing(config).at(t)` returns the live `{ center, radius, damagePerSecond, shrinking }` (radius/center interpolate during each shrink window, hold between phases); `isOutside(t, pos)` / `distanceOutside(t, pos)` test a point, and `damageOutside(t, dt, positions)` returns per-entity `{ id, damage }` for everyone beyond the wall — feed those into `scene.entity.stats.delta`/`effect` each tick.
1098
- **Extraction session** (`session/extraction`) — the raid-scoped "reach an extract and leave to bank what you carried" wrapper (Tarkov/DMZ/Helldivers), composed from the contested channel + `inventory/storageTier`. `createRaidSession({ extracts, insurance?, consolation? })`: `beginExtract(userId, extractId, team?)` opens a hold-to-leave channel, `tickExtract`/`damage` drive it, and on completion `resolveExtraction(userId, containers)` banks everything carried. `resolveDeath(userId, containers, now, rng?)` runs `partitionOnDeath` (banked kept, carried lost), schedules insured items through the built-in delivery queue (`claimDeliveries(now)` drains it on the clock), and yields the consolation loadout id. `playerSnapshot(userId)` feeds the extraction-timer HUD.
1099
- **Persistence scopes** (`runtime/persistenceScope`) — the run-vs-meta split with explicit reset boundaries (Icarus mission wipe, Once Human season reset). `partitionScopes(state, { run })` splits a flat record into `{ meta, run }` by key; `resetRun` clears the run half while meta (talents/blueprints/account currency) survives; `clearRunFields(playerRow, runFields)` and `applyRunReset(profile, runFields, now)` do the same over `RuntimePlayerRow`/`PlayerProfileRecord`. `planScenarioReset({ gameId, serverId?, wipeChunks?, wipeServerSession?, resetPlayers?, runFields? })` normalizes a scenario/season reset that `HostPersistence.resetScenario?(reset)` applies — `@jgengine/sql` implements it (deletes the server's chunks + session, run-resets each profile in one transaction), keeping account meta intact.
1100
-
1101
- ## Trade
1102
-
1103
- Catalog `trade` fields drive everything — no duplicate price lists.
1178
+ Use this skill for the **visual and interaction design of the game interface**: title screens, HUDs, menus, prompts, maps, inventories, dialogue, touch controls, transitions, pause/results states, accessibility, and screenshot critique.
1104
1179
 
1105
- ```ts
1106
- ctx.game.trade.canBuy(itemId, shopId, count?) // → reason | null
1107
- ctx.game.trade.canSell(itemId, count?)
1108
- ctx.game.trade.buy(itemId, count, { shop, inventoryId }) // charge → put, rolls back on failure
1109
- ctx.game.trade.sell(itemId, count, { shop, inventoryId })
1110
- ctx.game.trade.tradableAt(shopId, allItemIds) // derive stock from catalogs
1111
- ```
1180
+ Do not use this skill as a React, routing, state-management, or hooks reference. The main `jgengine` skill owns routing and points to the engine APIs. When implementation needs `@jgengine/react` hooks or shell APIs, follow the links in the main skill and the compact API appendix in [reference.md](reference.md); keep this skill focused on what the player sees and feels.
1112
1181
 
1113
- ## Economy and unlocks
1182
+ ## Required outcome
1114
1183
 
1115
- ```ts
1116
- ctx.game.economy.balance(userId, currencyId) / grant(...) / charge(...) // charge → { reason } | null
1117
- ctx.game.unlocks.has(userId, id) / grant(userId, id) / list(userId) / tree(categoryId)
1118
- ```
1184
+ A JGengine game must read as a self-contained game, not a responsive website with a canvas inside it.
1119
1185
 
1120
- Catalog `requires: [unlockId]` gates validate at command time.
1186
+ Before shipping UI:
1121
1187
 
1122
- ## Crafting, tech tree & production
1188
+ 1. Give the game a concise UI art direction.
1189
+ 2. Compose explicit desktop/mobile game layouts instead of document flow.
1190
+ 3. Keep persistent HUD information sparse and hierarchical.
1191
+ 4. Adapt touch controls to the genre and reserve their screen zones.
1192
+ 5. Implement authored focus, pressed, selected, disabled, success, failure, and warning states.
1193
+ 6. Add purposeful motion and feedback.
1194
+ 7. Capture screenshots and revise what actually renders.
1123
1195
 
1124
- Four **pure** primitives (no ctx, no renderer) for survival-crafting, tech-tree, factory, and farming games. All are catalog-first: recipes, tech nodes, production rates, and crop stages are game **data** you feed the primitive — the engine owns the graph math, the timers ride `ctx.time` (game-seconds), never wall-clock.
1196
+ ## Ownership boundary
1125
1197
 
1126
- **Recipe graph** — `@jgengine/core/crafting/recipe`. A `RecipeDef` is `{ id, inputs: RecipeItem[], outputs: RecipeItem[], seconds?, station?, stationRange?, requires? }` — inputs + optional required-workstation-in-range + time → outputs. `craft(state, layout, traits, recipe, context)` consumes inputs and produces outputs on an `InventoryState` **atomically** (rejects `missing-inputs` / `no-station` / `locked` / `no-output-space` without mutating on failure); `canCraft(...)` is the dry-run. `context = { origin?, stations?, unlocked? }`: `stationSatisfied` checks a matching placed workstation (`{ catalogId, position }`) within `stationRange` of `origin`, and `requires` gates on `unlocked(id)` (wire it to `ctx.game.unlocks.has` or the tech tree). `createRecipeGraph(defs)` indexes recipes by `producing(itemId)` / `using(itemId)` / `category`. Long crafts schedule completion with `ctx.time.after(craftSeconds(recipe), …)`.
1198
+ The main `jgengine` skill owns intake, engine architecture, API routing, hooks, commands, state, and verification routing. This skill owns presentation quality.
1127
1199
 
1128
- **Tech tree** — `@jgengine/core/economy/techTree`. **Generalizes flat `unlocks`, does not duplicate it**: a `TechNodeDef extends UnlockDef` adds `requires` (prerequisite node/unlock ids), an optional `recipe` payload, and `grants` (extra flat unlock ids). A node id **is** an unlock id, so flat unlocks are just tech nodes with no `requires`. `createTechTree(defs)` wraps `createUnlocks` internally and gates grants on prerequisites: `unlock(userId, id)` refuses until every `requires` is met, `available(userId)` is the reachable frontier, `recipes(userId)` lists the recipe payloads a player has unlocked (feed them to the recipe graph). `tree(categoryId)` and per-user `has`/`list`/`snapshot`/`hydrate` mirror `unlocks`.
1200
+ Read [reference.md](reference.md) when building or reviewing a game interface. It contains the implementation quality bar, layout rules, art-direction template, touch-control requirements, acceptance criteria, and the compact existing React API surface.
1129
1201
 
1130
- **Production building** — `@jgengine/core/crafting/production`. `productionBuilding({ id, inputs, outputs, rate, power?, bufferMultiplier? })` — a placed building that consumes buffered inputs and emits outputs on a timer. `rate` is production **cycles per game-second**; `tickProduction(def, state, { dt, powered? })` advances continuously through `dt` (so pause/fast-forward apply for free) and completes as many cycles as the buffer allows. `feedProduction` / `drainOutput` move items in and out of the internal buffers (a puller/conveyor). `advanceTransport(path, items, dt)` slides items along a belt and splits off `delivered`. `resolvePowerGrid(supply, consumers)` powers demands greedily until supply is exhausted — gate a building's tick on `powered`.
1202
+ ## Non-negotiable defaults
1131
1203
 
1132
- **Farming** `@jgengine/core/crafting/crop`. `CropTileState` is a soil state machine (`untilled` → `tilled` → planted); `tillTile` / `plantCrop` / `waterTile` are pure tile transitions and `advanceCropDay(def, tile)` runs the **day tick** — a `CropDef { stages, regrowDays?, needsDailyWater?, harvest? }` advances a growth stage per watered day and sets `harvestable`; `harvestCrop` yields and either clears the tile or resets a regrow crop. `applyToolToTiles(tiles, center, pattern, apply)` applies a tool across a tile pattern under the cursor — `singleTile()`, `squarePattern(r)`, `diamondPattern(r)`, `rectPattern(w,d)` (watering-can / hoe AoE). `createCropField(catalog)` is the stateful wrapper over a tile grid (`till`/`plant`/`water`/`harvest`/`advanceDay`); drive `advanceDay()` off the calendar day rolling over — `createDayTicker(startDay)` reports how many days `ctx.time.calendar().day` has crossed.
1204
+ - Active play owns the viewport; no marketing header, page title bar, document scrolling, or website container.
1205
+ - Screen placement belongs in the game's `ui/GameUI.tsx` composition layer.
1206
+ - Persistent gameplay information is frameless unless a physical/diegetic frame is part of the game's art direction.
1207
+ - Instructions are contextual and temporary, not permanent keyboard grids.
1208
+ - Mobile controls share input mechanics but not one universal visual skin.
1209
+ - Themes change geometry, composition, typography roles, icons, motion, materials, sound, and density—not only colors.
1210
+ - Ordinary rounded cards, pill buttons, generic dark modals, and dashboard grids are fallback failures, not defaults.
1133
1211
 
1134
- ## `applyLoadout`
1212
+ ## Preview states ship with the UI
1135
1213
 
1136
- ```ts
1137
- ctx.player.loadout.register(loadouts) // onInit
1138
- ctx.player.applyLoadout(userId, loadoutId) // → null | { reason }
1139
- ```
1214
+ Every game ships `src/preview.tsx`: a static default frame (default export, used by the website card) plus a `states` named export (`GamePreviewStates` from `@jgengine/react/preview`) keying named UI states — `stage_1`, `game_over`, `boss_intro` — to components. Build state entries from the game's **real UI components** with fixture snapshots (canned props/state), not redrawn lookalikes; that turns every key into a capturable render test. Capture any state instantly with `bun run shoot <game> --preview <stateKey>` — no sim, no three.js, no hang risk — and use it as the screenshot-critique loop for HUD/menu/overlay work before any full-shell `--mode ui`/`play` glance.
1140
1215
 
1141
- `LoadoutDef = { inventories?: { hotbar: [{ item, count, slot? }], … }, stats?, economy?, unlocks? }`. Application is **all-or-nothing**: every inventory put dry-runs first; any rejection applies nothing. Starter kits gate on `ctx.player.isNew`; class/respawn kits run from commands. Never scatter raw `put`/`grant` calls for a kit.
1216
+ ## Rejection test
1142
1217
 
1143
- ## Quests
1218
+ Reject and revise the UI when it could be mistaken for a SaaS dashboard, landing page, admin panel, documentation page, or generic emulator overlay.
1144
1219
 
1145
- ```ts
1146
- ctx.game.quest.register(catalog) // onInit
1147
- canAccept / accept / abandon / canTurnIn / turnIn / grant / revoke
1148
- progress(userId, questId, objectiveId, delta)
1149
- list(userId) / has(questId)
1150
- bind("entity.died") // kill objectives match objective.target === catalogId
1151
- bind("inventory.added") // collect objectives match objective.item
1152
- ```
1220
+ # JGengine UI — game presentation reference
1153
1221
 
1154
- Catalog: `{ id, title, giver?, turnIn?, requires?, objectives: [{ id, kind, target?/item?, count, partyShare? }], rewards? }`. `requires` is satisfied by a completed quest of that id or an unlock. `turnIn` applies declarative `QuestRewards` `{ xp?: { amount }, economy?: Record<string, number>, items?: { item, count, inventory }[], unlocks?: string[], quests?: string[] }` — note `xp` takes an `{ amount }` wrapper (applied via `stats.delta` + your level-up loop) and each reward `item` names the `inventory` it fills; chained `quests` are auto-offered if acceptable. Events: `quest.accepted` / `quest.updated` / `quest.completed`. `partyShare: { radius, credit: "all" | "tagger" }` extends kill credit to nearby party members.
1222
+ This reference defines the required visual and interaction quality for JGengine games. The main `jgengine` skill owns engine architecture, hooks, input commands, and routing. This document owns what the interface looks like, how it is composed, how it responds, and how it is verified.
1155
1223
 
1156
- ## Social
1224
+ ## The rule
1157
1225
 
1158
- ```ts
1159
- ctx.game.social.friends.canRequest / request / accept / decline / remove / block / list / requestsFor // persisted
1160
- ctx.game.social.party.register({ maxMembers }) // then canInvite / invite / accept / decline / kick / leave / promote / list / membersOf / invitesFor
1161
- ctx.game.social.presence.get(userId) // { online, serverId?, zoneId?, instanceId? }
1162
- ctx.game.social.emotes.play(fromUserId, emoteId, radius?) // → { from, emoteId, at, recipients } | { reason }
1163
- ctx.game.social.worldInvites.invite(fromUserId, toUserId, { serverId, joinCode? }) // then canInvite / accept / decline / listFor
1164
- ```
1226
+ A game must visually own its viewport. It must not resemble a dashboard, landing page, documentation page, or ordinary responsive web app.
1165
1227
 
1166
- Party is ephemeral session state (invites expire; leader leaving promotes the next member). Events: `social.friend.added`, `social.party.joined`, `social.party.left`.
1228
+ HTML and React are valid implementation tools. Website visual grammar is not the default.
1167
1229
 
1168
- **World invites** bridge friends and `multiplayer/matchmaking`: an invite carries the `{ serverId, joinCode? }` of the session you're in (the same fields as a `SessionListing`); `accept(userId, inviteId)` → `{ target }` is the join target you hand to your backend's `joinServer`/`joinByCode` — the invite never joins anything itself. Invites are ephemeral like party invites (TTL via `SocialDeps.worldInviteTtlMs`, default 60s; blocked users can't invite either direction). Events: `social.world.invited`, `social.world.accepted`. React: `useWorldInvites()` lists pending invites for the local player.
1230
+ ## 1. Start with a concise UI art direction
1169
1231
 
1170
- `emotes.play` reuses `scene.entity.inRadius` to find nearby **player**-role entities (default radius 20) and emits `emote.played` — never build a parallel proximity broadcast. Emote ids are game-defined strings (no registration, same convention as effect ids). Bind it into the existing feed primitive for a HUD feed: `ctx.game.feed.bind("emote.played")` + `useFeed({ action: "emote.played" })` — no dedicated emote hook exists or is needed.
1232
+ Before implementing screens, write this short block:
1171
1233
 
1172
- ## Chat
1234
+ ```md
1235
+ UI ART DIRECTION
1173
1236
 
1174
- ```ts
1175
- ctx.game.chat.send(fromUserId, channelId, body) // → { message, recipients } | { reason }
1176
- ctx.game.chat.whisper(fromUserId, toUserId, body) // stable per-pair channel "whisper:<a>:<b>"
1177
- ctx.game.chat.history(channelId, { limit?, viewerUserId? }) // viewer filter drops blocked senders
1178
- ctx.game.chat.register({ id, kind, radius?, historyLimit?, rateLimit? }) // custom channels
1179
- ctx.game.chat.channels() / snapshot() / hydrate(data)
1237
+ Player fantasy:
1238
+ Emotional tone:
1239
+ Shape language:
1240
+ Material language:
1241
+ Typography roles: display / body / numerical / labels
1242
+ Motion language:
1243
+ Icon language:
1244
+ Sound language:
1245
+ Information hierarchy:
1246
+ Forbidden patterns:
1180
1247
  ```
1181
1248
 
1182
- Built-in channels: `global` (everyone), `party` (reuses `social.party.membersOf`; rejects "not in a party"), `proximity` (reuses the same spatial/entity seam as emotes, default radius 20, **player**-role entities only). `kind` picks the recipient resolution; custom channels pick one of the three kinds. Sends are trimmed, capped (500 chars), and rate-limited per user per channel (default 10/10s, sliding window — `createChatRateLimiter` is the reusable pure primitive). Mute rides social's blocked set: blocked pairs can't whisper, and blocked senders are dropped from party/proximity recipients and from `history` when `viewerUserId` is passed. Every send emits `chat.message` (recipients omitted = broadcast). History is a bounded ring per channel (default 100) with `snapshot`/`hydrate` like `Friends`.
1183
-
1184
- **Remote chat seam** (`multiplayer/chatContract`): `ChatTransport` is the hook-shaped contract (`useMessages(channelId | "skip")` / `useActions()`, identity-stable like `PresenceTransport`); `ChatSync` is the callback shape for backends that can't host React hooks. Bindings: ws — `createWsBackend(...).chatSync` / `.chatSyncFor(serverId)` over `chatSend` frames + a `chat` update channel (host relays per-channel rings, validates length + rate limit); Convex — `@jgengine/convex/convexChatTransport` `createConvexChatTransport({ messages, sendMessage })` (one live query + one mutation); local/dev — `createLocalChatTransport()`. React lifts a `ChatSync` via `chatTransportFromSync`.
1249
+ Keep it practical. It should directly influence layout, silhouettes, controls, timing, and materials.
1185
1250
 
1186
- ## Cosmetic loadout
1251
+ Example forbidden patterns:
1187
1252
 
1188
- ```ts
1189
- ctx.player.cosmetics.register(defs) // onInit — Record<loadoutId, { slots: Record<slot, cosmeticId> }>
1190
- ctx.player.cosmetics.apply(userId, loadoutId) // merges the preset's slots
1191
- ctx.player.cosmetics.equip(userId, slot, cosmeticId | null) // set/clear one slot directly
1192
- ctx.player.cosmetics.get(userId) // Record<slot, cosmeticId>
1193
- ```
1253
+ - generic rounded dashboard cards
1254
+ - pill buttons
1255
+ - long centered paragraphs during play
1256
+ - ordinary two-column form layouts
1257
+ - persistent keyboard-instruction grids
1258
+ - multiple equally weighted bordered panels
1259
+ - generic translucent mobile circles
1260
+ - large website-style modals
1261
+ - document-flow wrapping used as HUD layout
1194
1262
 
1195
- A per-player appearance layer distinct from `applyLoadout` (which grants inventory/stats/economy/unlocks) cosmetics never touch gameplay state, only equipped slot ids for your renderer to read. Emits `cosmetics.changed`.
1263
+ A theme is not complete when only colors and fonts change. It must also affect composition, geometry, spacing rhythm, borders, icons, animation, sound, information density, terminology, button construction, and touch controls.
1196
1264
 
1197
- ## Possession
1265
+ ## 2. Screen inventory and hierarchy
1198
1266
 
1199
- ```ts
1200
- ctx.player.possession.own(userId, entityId) / disown / owns / listOwned(userId)
1201
- ctx.player.possession.active(userId) // → entityId, defaults to userId itself
1202
- ctx.player.possession.possess(userId, entityId) // → null | { reason } — must be owned + spawned
1203
- ```
1267
+ Identify the screens the game actually needs:
1204
1268
 
1205
- A player can own N scene entities (party members, vehicles, a possessed creature) and control exactly one at a time — distinct from `game.social.party`, which is a social grouping, not a control model. `possess` flips the previous/next entity's scene `EntityRole` between `"player"`/`"npc"` and emits `possession.swapped`; `@jgengine/shell`'s `GamePlayerShell` reads `active(userId)` every frame to rebind WASD movement, tab-targeting, hotbar `from`, and the camera rig's `followEntityId` to whichever entity is currently controlled — a game never wires this rebind itself.
1269
+ - boot/loading
1270
+ - title or attract screen
1271
+ - mode selection
1272
+ - onboarding/tutorial
1273
+ - gameplay HUD
1274
+ - pause
1275
+ - settings
1276
+ - map/inventory/dialogue where relevant
1277
+ - victory/results
1278
+ - failure/retry
1206
1279
 
1207
- ## Form / shapeshift
1280
+ For each screen, define:
1208
1281
 
1209
- ```ts
1210
- ctx.scene.entity.form.register(defs) // onInit FormDef[] = { id, movement?, abilities?, model? }
1211
- ctx.scene.entity.form.shapeshift(instanceId, formId, durationSeconds?) // null | { reason }
1212
- ctx.scene.entity.form.active(instanceId) // formId | null
1213
- ctx.scene.entity.form.abilities(instanceId) // readonly string[] | null
1214
- ctx.scene.entity.form.revert(instanceId) // early revert
1215
- ```
1282
+ - the player’s primary question
1283
+ - the primary action
1284
+ - the most important information
1285
+ - what can be hidden
1286
+ - what belongs in-world instead of in the HUD
1216
1287
 
1217
- A `form` bundles movement params + an ability-id list + a mesh into one swappable unit (shapeshift/transformation — V Rising bear/wolf/bat, Wukong's boss transformation). `model` reuses the entity's catalog `name` (the same key `entityModels`/`entitySprites` resolve against), so the mesh swap rides the existing render lookup — no parallel mesh field. `durationSeconds` is **game time**: it schedules the automatic revert through `ctx.time.after`, so it obeys pause and fast-forward like everything else on the clock. Emits `form.changed`.
1288
+ ### HUD tiers
1218
1289
 
1219
- ## Events, feed, leaderboard
1290
+ **Tier 1 immediate action and survival**
1291
+ Health, timer, current target, ammo, danger, capture state.
1220
1292
 
1221
- ```ts
1222
- ctx.game.events.on(name, handler) // register in onInit; typed GameEventMap
1223
- ctx.game.feed.bind(action) // pipe an engine event into a ring buffer (default 20)
1224
- ctx.game.feed.push(action, entry) // manual channels (chat, crafting)
1225
- ctx.game.feed.recent(action, { limit? })
1226
- ctx.game.leaderboard.track({ stat, scope: "global" | "server" | "profile" }) // onInit
1227
- ctx.game.leaderboard.increment(userId, stat, { scope, by? }) / getTop / getProfile
1228
- ```
1293
+ **Tier 2 — short-term decisions**
1294
+ Objective progress, route progress, cooldowns, combo, pursuit distance.
1229
1295
 
1230
- `ctx.game.commands.define(name, { validate?(ctx, input), apply(ctx, input) })` registers a verb (`has`/`names`/`run` round it out); `run(name, input)` returns `{ status: "applied", state } | { status: "rejected", reason } | { status: "unknown-command" }`. `apply` may either **return** the next state (the classic reducer shape) or mutate `ctx` in place and return **nothing** `run` keeps the current `ctx` as `state` when `apply` returns `void`, so a handler that only calls other `ctx` methods (spawn, effect, loot.grantToPlayer, …) doesn't need a pointless `return ctx`. **Event handlers use `ctx` directly** the same way (side effects: leaderboard, economy, scheduling) and never reassign state. One feed primitive for kill feeds, loot logs, quest updates — no per-domain feed hooks.
1296
+ **Tier 3reference information**
1297
+ Full map, inventory, controls, schedule, mission details, lore.
1231
1298
 
1232
- ## `ctx.game.store` reactive game state
1299
+ Tier 1 is immediately readable. Tier 2 is quieter. Tier 3 is usually hidden until requested.
1233
1300
 
1234
- ```ts
1235
- ctx.game.store.set("health", 100) // any key, any value type
1236
- ctx.game.store.get("health") // T | undefined
1237
- ctx.game.store.has("health")
1238
- ctx.game.store.delete("health")
1239
- ctx.game.store.subscribe(listener) // change-signal fires on set/delete
1240
- ctx.game.store.mapSnapshot() / arraySnapshot()
1241
- ```
1301
+ Do not style every datum as an equally important bordered box.
1242
1302
 
1243
- A reactive per-game keyed store (`ObservableKeyedStore<unknown>`) attached to `GameContext` — reach for it instead of a module-level singleton store for ad-hoc reactive game state (turn trackers, deck UIs, anything that doesn't already have a `ctx` surface). `set`/`delete` bump `ctx.version()` and notify `ctx.subscribe` listeners; `get`/`has` are plain reads. Unlike a per-slot handle, there is no `define`/seed step — a key simply doesn't exist until the first `set`.
1303
+ ## 3. Full-viewport game composition
1244
1304
 
1245
- ## `ctx.game.cards` / `ctx.game.turn` lazily-created piles and turn loops
1305
+ The active game should behave like an application mode:
1246
1306
 
1247
- `ctx.game.cards.pile(id, config?)` and `ctx.game.turn.loop(id, config?)` lazily create (config required on first call) or return the existing notify-wrapped `CardPile`/`TurnLoop` for `id` — call with just the id after the first `onInit` seed to fetch the same instance; every mutating method is wrapped so it bumps `ctx.version()`/notifies `ctx.subscribe` automatically, same as every other `ctx` surface. This replaces manually constructing `createCardPile`/`createTurnLoop` and wiring notification yourself.
1307
+ - own the full viewport
1308
+ - avoid document scrolling
1309
+ - avoid site navigation and marketing chrome during play
1310
+ - respect safe-area insets
1311
+ - keep exit/settings/fullscreen controls minimal
1312
+ - separate world, HUD, controls, screens, and system overlays
1248
1313
 
1249
- ## Movement, pose, input
1314
+ Recommended layer contract:
1250
1315
 
1251
- ```ts
1252
- ctx.player.movement.getPose(id) / setPose(id, "crouch") // validates catalog movement.poses
1253
- ctx.player.movement.getAim(id) / setAim(id, "ads") // ADS = aim state + zoom modifier, not a pose
1316
+ ```tsx
1317
+ <GamePlayer>
1318
+ <WorldLayer />
1319
+ <HudLayer />
1320
+ <ControlLayer />
1321
+ <ScreenLayer />
1322
+ <SystemLayer />
1323
+ </GamePlayer>
1254
1324
  ```
1255
1325
 
1256
- Poses (`standing/crouch/prone/running`) change the collision capsule (`POSE_HITBOX`); aim pairs with a `player.stats` zoom modifier on `"reticle"`. Game code reads action names only (`isDown("aim")`, `wasPressed("interact")`) — hold vs toggle is resolved by the binding config, never by raw key branches.
1257
-
1258
- ### `ctx.input` — polling the raw controls
1259
-
1260
- `ctx.input` (`@jgengine/core/runtime/inputSnapshot`) is a per-frame held-action snapshot for `onTick` to poll, distinct from the command-dispatch path (bound actions still run commands the normal way): `publish(held: readonly string[])` (the shell calls this once per frame before `onTick`), `isDown(action)`, `held()` for the full list. Publishing never bumps `ctx.version()` — it's a poll surface, not reactive state.
1261
-
1262
- **`repeatMs`** — bind an action as `{ hold: [...], repeatMs: 150 }` (`input/actionBindings`) and the shell fires its command on the down edge, then again every `repeatMs` while held, resetting on release (hotbar-style repeat-fire without a per-game timer).
1326
+ - `WorldLayer`: game renderer
1327
+ - `HudLayer`: non-blocking gameplay information
1328
+ - `ControlLayer`: touch/input surfaces
1329
+ - `ScreenLayer`: title, pause, settings, tutorial, victory, failure, transitions
1330
+ - `SystemLayer`: exit, fullscreen, engine settings, devtools
1263
1331
 
1264
- **Aim on generic commands** every command resolved from a bound action now runs with `{ yaw, pitch, aim: { yaw, pitch } }` in its payload, so a handler can read the camera-relative aim without going through `pointer.worldHit()`.
1332
+ Do not place unrelated interface pieces into one ordinary DOM flow.
1265
1333
 
1266
- ### Controller kinematics movement config, physics tuning, respawn
1334
+ ## 4. Explicit game layout modes
1267
1335
 
1268
- `PlayableGame.movement` (`PlayerMovementConfig`) tunes the shell's built-in walk controller (never touch `PhysicsWorld` for ordinary player movement):
1336
+ Do not rely on generic responsive wrapping. Compose explicit modes such as:
1269
1337
 
1270
- ```ts
1271
- movement: {
1272
- mode?: "free" | "axis" | "grid"; // "free" (default) camera-relative; "axis" locks travel to one world axis; "grid" snaps each committed position to cell centers
1273
- axis?: "x" | "z"; // world axis for mode "axis". Default "x"
1274
- cellSize?: number; // cell size for mode "grid". Default 1
1275
- collideObjects?: boolean; // collide the walking player against placed scene objects (unit-box AABBs) even without collision.voxel
1276
- beforeCommit?: (frame: MovementCommitFrame) => readonly [number, number, number] | undefined | void; // intercepts each frame's resolved position before the pose commits; return a replacement to constrain/redirect the step
1277
- }
1278
- ```
1279
-
1280
- `nav/navConstrain`'s `constrainToNavGrid(grid, { y? })` is a standalone walkable-pass-through + wall-sliding helper over a `nav/navGrid`; its `(proposed, entity)` shape doesn't match `beforeCommit`'s `(frame) => [x,y,z]` signature directly, so wire it in with a small adapter closure rather than passing it straight through.
1338
+ - desktop-wide
1339
+ - desktop-compact
1340
+ - mobile-landscape
1341
+ - mobile-portrait
1281
1342
 
1282
- `defineGame({ physics: { gravity, jumpVelocity } })` drives the kinematics controller directly: `gravity` (signed, e.g. `-24`) and `jumpVelocity` override the built-in tuning; omit either to keep the defaults. This is the one global exception to "never player tuning in `defineGame`" — it configures the shared controller, not a catalog entry. It is still **distinct** from `physics/physicsWorld`'s standalone rigid-body sim (see below) — `defineGame.physics` never touches that sim.
1343
+ A mobile layout is not a shrunken desktop HUD.
1283
1344
 
1284
- **Vertical motion intents** — `ctx.player.motion` (`@jgengine/core/runtime/motionIntents`): `impulse(vy)` adds to the vertical velocity the shell's controller is about to integrate, `setVerticalVelocity(vy)` replaces it outright, `setY(y)` wins over physics for that frame. The shell calls `takePending()` once per frame, before integrating gravity, to drain what accumulated; this is not reactive state (jump pads, launch abilities, bounce pads).
1345
+ On mobile:
1285
1346
 
1286
- **`collision: { voxel: true }` — object lattice as solids.** When set, the shell's local player uses a voxel body whose `isSolid(x,y,z)` is rebuilt from `ctx.scene.object.list()` as exact `` `${x},${y},${z}` `` keys (integer cell queries). Integer-placed objects are walkable/blocking; fractional-coord objects decorate without colliding. Removing an object opens a real trapdoor under gravity — do not fake the fall with `setPose`. `visual.scale` does not shrink the collider (always a unit cell). The voxel body is created once; prefer `motion.setY` / `impulse` for vertical relocation — full XY teleport of the local voxel body is not supported. Solid cache rebuilds when object **count** changes. Recipe: `jgengine-newgame` → voxel trapdoor board.
1347
+ - reserve thumb-control zones
1348
+ - keep critical HUD out of those zones
1349
+ - hide keyboard legends
1350
+ - reduce persistent information
1351
+ - move Tier 3 information behind contextual panels
1352
+ - respect browser and device safe areas
1353
+ - support portrait only when intentionally designed
1354
+ - otherwise show a polished rotate-device state
1287
1355
 
1288
- **Respawn** `ctx.scene.entity.spawnPoseOf(id)` reads the spawn pose, `resetToSpawn(id)` teleports back to it with zero velocity, `resetAllToSpawn(filter?)` does it for every entity matching an optional filter and returns the count (round resets, out-of-bounds recovery).
1356
+ All viewport anchoring should live in the game’s top-level UI composition file. Child components own their internal layout, not their screen position.
1289
1357
 
1290
- ## Touch & mobile
1358
+ ### Design-resolution fit (`platforms` + `hudFit`)
1291
1359
 
1292
- Every game is touch-playable with zero per-game input code. On a coarse-pointer device the shell derives a `TouchScheme` from the game's `input` bindings (`deriveTouchScheme`, `@jgengine/core/input/touchScheme`): a virtual joystick binds whichever of `moveForward`/`moveBack`/`moveLeft`/`moveRight` (or `turnLeft`/`turnRight`) are bound, on-screen buttons cover the remaining actions, and drag-to-look mounts automatically for `first`-person camera rigs. Touch controls feed synthetic `touch:<action>` codes into the same `ActionStateTracker` the keyboard uses game code reads `isDown`/`wasPressed` and never branches on input source.
1293
-
1294
- Refine the derived scheme with the `touch` field of `defineGame({...})` (`TouchControlsConfig`, all optional):
1295
-
1296
- ```ts
1297
- touch: {
1298
- gestures: {
1299
- tap: "rotateCw",
1300
- swipeUp: "hold",
1301
- swipeDown: "hardDrop",
1302
- drag: { left: "shiftLeft", right: "shiftRight" },
1303
- },
1304
- buttons: [
1305
- { action: "rotateCcw", label: "CCW" },
1306
- { action: "softDrop", label: "Soft" },
1307
- ],
1308
- },
1309
- ```
1360
+ Design-resolution fit is on by default for every game: each `HudCanvas` auto-scales from `hudFit.designSize` (default 1600×900) down to the live viewport, clamped by `hudFit.minScale`/`maxScale` (default 0.4–1), so the authored layout shrinks instead of overflowing a phone. No declaration needed. `hudFit.mobile` overrides the fit on compact displays only tune the phone presentation there instead of hand-rolling media queries. The player's Graphics UI scale setting multiplies the computed scale on every platform. Declaring `platforms: ["web"]` (without `"mobile"`) opts a desktop-only game out; its compact displays keep the legacy fixed 0.85 zoom.
1310
1361
 
1311
- - **`gestures`** bind `tap` / `swipeUp` / `swipeDown` / `swipeLeft` / `swipeRight` / `drag` (`{ left?, right?, up?, down?, stepPx? }`, repeats its action every `stepPx` of travel) on the play surface. An action consumed by a gesture is removed from the derived button set.
1312
- - **`buttons`** — curate the on-screen cluster (order preserved; bare string or `{ action, label?, icon? }`); omit to auto-derive one button per remaining bound action. Buttons render a glyph, not text: `iconForAction` (`@jgengine/react/gameIcons`) resolves the action name to a `GameIconName` (`jump`, `sprint`, `rotateCw`, `hardDrop`, `swap`, `hand`, `restart`, arrows, …), the `label` becomes the `aria-label`; set `icon: "<GameIconName>"` to pick one explicitly or `icon: false` to force the text label.
1313
- - **`hidden`** — actions to drop from the derived buttons without gesture-binding them.
1314
- - **`movement: false`** — suppress the virtual joystick even when movement actions are bound.
1315
- - **`look` / `lookSensitivity`** — drag-to-look on the play surface; defaults to `true` for `first`-person camera rigs, `0.005` radians/px.
1316
- - **`touch: false`** — opt out entirely when the game's own DOM UI is already touch-native.
1317
-
1318
- `useDisplayProfile()` (`@jgengine/react/display`) reports `{ coarsePointer, compact, portrait }` — live media-query state, SSR-safe — for adaptive HUD layout; see the mobile/touch rules in [`reference/ui-react.md`](reference/ui-react.md).
1319
-
1320
- ## Interaction — `proximityPrompt`
1321
-
1322
- One primitive for all float UI: `{ radius, display, invoke }` where `display` is `{ kind: "keybind", actionId }` | `{ kind: "gauge", gaugeId }` | `{ kind: "label", text }` and `invoke` is `{ command, args? }` or null (display-only). `talkable: "dialogue_id"` on an entity expands to a talk prompt. Engine picks the nearest prompt in radius (priority tie-break). Never build per-game hint resolver chains.
1323
-
1324
- ## Pointer-driven input and navigation
1325
- The **pointer is a service, not per-game glue**. Opt in with `camera` plus a `pointer` config in `defineGame({...})`; the shell casts the cursor into the world and dispatches commands you define — verbs stay commands, catalogs stay data.
1326
- - **`pointer.worldHit()` (shell service).** The shell raycasts the cursor to `{ point, normal, entity, object }` (a renderer-free `PointerHit` from `@jgengine/core/input/pointer`) — entity/object are the topmost instance ids under the cursor, else `null`, with a ground-plane fallback for open terrain. Consume it renderer-free: `aimToPoint(origin, point)` builds an `Aim` for `item.use`/projectiles (ground-target skillshots, twin-stick), `groundOf(hit)` drops to `[x, z]` for routing. `pointer.worldHitCenter()` is the same raycast pinned to the viewport center instead of the live cursor — the reticle-aim query a locked/hidden-cursor rig (first-person, gamepad) needs when there is no cursor position to read.
1327
- - **The `pointer` field of `defineGame({...})`** (all optional): `moveCommand` (left-click ground → `run(cmd, { point, entity, object })`, click-to-move), `select` (left-drag marquee + single-click box-select of entities), `orderCommand` (right-click ground → `run(cmd, { selection, point })`, issue a command to the selection), `contextMenu` (right-click an entity/object → its catalog `verbs` menu), `secondaryCommand` (right-click ground/entity/object → `run(cmd, { point, entity, object, aim })` when neither `orderCommand` nor `contextMenu` claims the click — a generic right-click verb for games with no selection/RTS model), `aim` (route the primary ability's aim to the cursor), `grabWorldItems` (left-click a `worldItem` within pickup radius → engine-owned `ctx.scene.worldItem.pickup`, no game command). Enabling `select`/`moveCommand` frees the left button for verbs; orbit moves to middle-drag.
1328
- - **`createDragCapture({ maxPull?, grabRadius? })`** (`@jgengine/core/input/pointer`) — a renderer-agnostic slingshot/drawback state machine: `begin(origin, at)` starts a pull (rejected outside `grabRadius`), `update(at)` tracks the cursor, `release()` returns the final `DragState { origin, current, pull, magnitude, fraction }` (pull clamped to `maxPull`), `cancel()` aborts. Angry Birds-style slingshots, bow drawback, throwable wind-up — pair with `aimToPoint` to fire.
1329
- - **Selection math** (`scene/selection`) is pure and testable: `createSelectionSet()`, `screenRect`/`selectWithinRect`/`isMarquee` over projected screen points.
1330
- - **Context menu** (`interaction/contextMenu`): a catalog entity/object carries `verbs: contextVerb(label, command, args?)[]`; the shell builds the menu with `buildContextMenu` and dispatches the chosen command via `contextVerbInput` (verb args + `target`/`point`, so one handler can walk-then-act).
1331
- - **Navmesh + A\*** (`nav/navGrid`): `createNavGrid({ bounds, cellSize, diagonal? })` → mark obstacles with `blockAabb`/`setWalkable`, or `populateNavGridFromEnvironment(grid, world)` to block every generated building's footprint on an `environment()` world's `structures` in one call (returns the count blocked) instead of hand-walking the district. `findPath(grid, from, to, { clearance?, smooth?, stepCost? })` returns a string-pulled `[x, z]` polyline (blocked start/goal snap to the nearest walkable cell) feeding **both click-to-move and AI routing**; `stepCost?(from, to)` multiplies the base cost of a grid step — `slopeStepCost(terrainField, weight?)` is the ready-made factory that penalizes steep terrain (routes around cliffs instead of over them). Renderer-free — AI and gameplay consume it without the shell.
1332
- - **`pathFollow`** (`nav/pathFollow`): the lighter authored-polyline mover for tower-defense creeps that needs no navmesh — `createPathFollow({ waypoints, speed, loop? })` + pure `advancePathFollow(config, state, dt)` (crosses multiple waypoints per tick, reports `done`/`heading`/`distanceTravelled`). Feed it a navmesh route with `pathFromNav(route, elevation, offset?)` and the same follower drives click-to-move — `elevation` is either a fixed `y` or a `{ sampleHeight(x, z) }` field (any `TerrainField` qualifies), so a route across relief rides the ground instead of a flat plane.
1333
- - **`constrainToNavGrid(grid, { y? })`** (`nav/navConstrain`) is a standalone walkable-pass-through + wall-slide helper: it passes through walkable moves, slides along walls at the navmesh boundary instead of stopping dead, and optionally remaps `y` to the grid. Its `(proposed, entity)` shape doesn't match `PlayerMovementConfig.beforeCommit`'s `(frame) => [x,y,z]` signature, so wire it in with a small adapter closure (see "Controller kinematics" above) to wall a player/AI to the same navmesh `findPath` already routes against.
1334
- ## AI — director, threat, jobs, crowds (`ai/*`)
1335
- Renderer-free AI over the same navmesh (`findPath`/`pathFollow`) gameplay already uses. Everything ticks on **game-time `dt`** (the `ctx.time` simClock delta), so it obeys pause and fast-forward for free. Manifests, patrol routes, job definitions, threat weights, and POIs are **game data** — the primitives own the loop, the catalog owns the content.
1336
- - **Spawn director** (`ai/spawnDirector`) — budgets and escalates spawns for wave shooters and difficulty directors (Brotato, Bloons TD 6, Risk of Rain 2, Helldivers 2, Deep Rock Galactic). `createSpawnDirectorState(config)` then pure `advanceSpawnDirector(config, state, dt, { alive, players? })` → `{ state, spawns: SpawnRequest[] }`. Each `WaveManifest` grants a `budget` spent on affordable weighted `SpawnEntry`s (`cost`/`weight`/`minWave`), capped by `maxAlive`; `duration` auto-advances waves (or call `advanceWave` on "wave cleared"). Budget also trickles via `budgetPerSecond`, ramps a difficulty curve with `escalationPerSecond` (grows with sim-time), scales with `playerBudgetPerSecond`, and surges on `raiseAlert(state, amount)` decaying over time (bug-breach/dropship escalation). Seeded (`seed`) so ticks are deterministic. `pickSpawnPoint(points, players, { roll, bias })` biases placement toward (or away from) players. `config.spawnPoints?: NavPoint[]` lets the director pick a point itself: each `SpawnRequest` then also carries `point` (the chosen `[x, z]`, biased by `config.spawnPointBias`) and `laneId` (the point's index into `spawnPoints`) — feed multiple named lanes/portals and read `laneId` back to route the spawned entity down its lane instead of correlating positions by hand.
1337
- - **Threat table** (`ai/threat`) — MMO/extraction aggro (Escape from Tarkov, WoW-style tanking). `createThreatTable({ decayPerSecond?, max?, forgetBelow? })`: `add(source, amount)` accumulates, `decay(dt)` bleeds off per game-second and forgets emptied sources, `highest({ current?, stickiness? })` returns the top-threat source to feed `scene/targeting` — `stickiness` (e.g. 1.1) keeps the current target until another exceeds it by that factor, so aggro doesn't jitter. `ranked()` for a threat meter.
1338
- - **Patrol** (`scene/behaviors`) — `patrol({ waypoints, speed, loop? })` is a `BehaviorDescriptor` (a route is data) that layers a fixed beat on top of `wander`; drive it with `createPathFollow`/`advancePathFollow` (lane creeps, scav patrols in Deadlock/Tarkov). Route waypoints between guard posts with `findPath`.
1339
- - **Job board** (`ai/jobBoard`) — colony/companion task assignment (Palworld stations, Schedule I employees, Sons of the Forest directives). `createJobBoard()`: `post(job)` a `JobDef` (`station`, `work` seconds, `priority`, `arriveRadius`, `repeat`), `claim(worker)` auto-pulls the highest-priority queued job or `assign(worker, jobId)` for a player order (steals it from its holder), `release` requeues. Per tick `advance(worker, dt, { distanceToStation })` runs the state machine `travelling → working → done` (path to `station(worker)` via `findPath`, occupy, run the loop), returning a `JobReport` on completion; `repeat` jobs re-run as a production loop and report each cycle.
1340
- - **Crowd flow** (`ai/crowd`) — many agents routing to their own points of interest with congestion (Two Point Museum corridors, Dave the Diver seating). `computeFlowField(grid, goals, { clearance?, congestion? })` runs Dijkstra from the goals over the walkable grid → `direction(point)`/`next(point)` steer any agent toward the nearest goal (no per-agent A*). `createCrowdField(grid)` tracks per-cell occupancy (`enter`/`leave`/`count`); pass `crowd.penalty(weight)` as the field's `congestion` to reroute flow around crowded cells each tick. `selectPoi(pois, from, { roll, occupancy?, distanceBias?, distance? })` weights a POI by appeal and proximity, skips ones at `capacity`, and accepts a `distance` override (e.g. `findPath` length) to choose over the navmesh, not line-of-sight.
1341
- ## Map, fog of war & ping
1342
- Minimap/world-map/fog/compass state is renderer-free core (`world/*`), the top-down terrain image bakes in the shell, and the minimap/compass/world-map are react components. Ping rides the existing party + feed — it is not a new channel.
1343
- - **Markers** (`world/markers`): `createMarkerSet()` is a reactive keyed set of `MapMarker { id, kind, position, label?, owner?, expiresAt?, meta? }` — `add`/`remove`/`get`/`list`/`query({ kind, owner, near, radius })`/`prune(now)`/`subscribe`. `kind` is a game-owned catalog string; `DEFAULT_MARKER_KINDS` (objective/enemy/loot/location/danger/ping/player/ally) supplies colors + glyphs the react map reads (override with your own `MarkerKindStyle` palette). Objective/entity/loot markers all live here.
1344
- - **Fog of war** (`world/fog`): `createFogField({ bounds, cellSize })` is reveal-on-event — `reveal(x, z, radius?)` (a dig/act), `revealAlong(from, to, radius?)` (a walked trail); once a cell is revealed it stays revealed. `isRevealed`/`fraction`/`cells()` (stable snapshot for rendering)/`reset`/`subscribe`.
1345
- - **Minimap math** (`world/minimap`): pure projection + bearings — `projectToMinimap(worldPoint, { center, worldRadius, size, rotate? })` → pixel `{ x, y, inside, distance }` (north = −Z maps up), `clampToMinimapEdge` for off-map markers, `compassBearing(from, to)`/`headingToBearing(yaw)`/`bearingToCardinal`/`relativeBearing` for the compass strip.
1346
- - **Ping** (`game/ping`): `classifyPing(hit, { roleOf, categoryOf }, options?)` turns a G1 `pointer.worldHit()` `PointerHit` into a category (hostile entity → `enemy`, tagged object → its catalog category, open ground/ally → `location`). `createPingSystem({ markers, feed, party?, ttlMs?, classify, classifyOptions? })` composes classify + broadcast: `ping(from, hit, category?)` classifies, drops a categorized marker, and pushes the `PingPayload` to the party feed under `PING_FEED_ACTION` (`"party.ping"`) — the shell's feed bridge fans it to the squad. `DEFAULT_PING_CATEGORIES` is the enemy/loot/location/danger wheel. Enable the verb with the `pointer.pingCommand` field of `defineGame({...})`: the shell binds the `ping` input action → `worldHit()` → runs your command with `{ point, entity, object, normal }`.
1347
- - **Shell render** (`@jgengine/shell/map`): `bakeTerrainMap(field, bounds, { resolution? })` renders a `TerrainField`/`RegionField` to a top-down PNG data-URL for the map background; `MapMarkerBeacons({ markers })` renders world-space beacons (the visible side of a ping) — wire via the `WorldOverlay` field of `defineGame({...})`. See the `extraction-map` demo game.
1348
- ## Sensors, vision & observer tools (`sensor/`)
1349
- Pure `@jgengine/core/sensor/*` primitives for querying and surfacing world state the player can't normally see or reach through the standard occlusion/proximity rules — reveal vision, hidden-state sensors, photo-mode framing, and session replay. Shell renderers/HUD pieces live in `@jgengine/shell/vision` and `@jgengine/shell/replay`.
1350
- | Primitive | Answers |
1351
- |-----------|---------|
1352
- | `createRevealQuery({ resolvePosition, resolveTags, candidates })` → `RevealQuery` | `inRadius(center, radius, tags)` — occlusion-ignoring tagged-entity radius query (Dark Sight / detective-vision reveal, #115). `inRadius` already never checks occlusion (only combat's AoE `effect()` layers a LoS filter on top of it) — this is that same query shaped for a vision readout: scoped to catalog-declared tags, sorted nearest-first |
1353
- | `probeHiddenState(origin, sources, { range, variableId, falloff? })` / `probeHiddenStateAll(...)` → `SensorReading \| null` | A sensor verb: reads a hidden zone/entity state variable (EMF/thermometer/geiger, #116) in range, strongest reading first; `strength` falls off linearly with distance by default |
1354
- | `projectToView(camera, point)` → `FrustumProjection` | Pure camera-frustum projection (no three.js) — `inView`, `screenX/screenY` (-1..1), `distance` |
1355
- | `framingScore(projection, config?)` → `number` | 0..1 framing quality from screen-center placement + distance-to-ideal (photo-mode "is this subject framed", #117) |
1356
- | `createFrustumSensor(config?)` → `FrustumSensor` | `tick(camera, targets, dt)` — per-target in-view + framing + `dwellSeconds` (resets the instant a target leaves frame); a view-frustum sensor on a held camera object (Content Warning-style monster-filming scoring) |
1357
- | `createRecordingBuffer(options?)` → `RecordingBuffer<T>` | `append(t, data)` / `seek(t)` / `range(fromT, toT)` — a session-recording buffer for replay/photo mode/kill-cam (#120), keyed on game-time so pause/fast-forward scrub consistently |
1358
- | `colorDistance(a, b)` / `concealmentScore(entityColors, backgroundColors)` / `createConcealmentSensor(config?)` → `ConcealmentSensor` | Camouflage/blend-in scoring — how well an entity's palette matches its surroundings (hide-and-seek, stealth camo checks) against a `threshold` |
1359
- | `createFreezeMonitor(config?)` → `FreezeMonitor` | Detects a tracked subject moving past a tolerance speed during a "freeze" window (red-light-green-light, statue games) and reports `FreezeViolation`s |
1360
- Shell wiring: `@jgengine/shell/vision/RevealVision` (`RevealHighlights` — depth-test-disabled 3D highlight meshes for tagged entities in radius, meant for `WorldOverlay`; `RevealScreenTint` — full-screen CSS tint for "vision mode is on", meant for `GameUI`), `@jgengine/shell/vision/HiddenStateProbeHud` (`SensorReadoutMeter` — needle-strength HUD readout), `@jgengine/shell/vision/FrustumSensorHud` (`FrustumSensorReadout` — drives the sensor off the live render camera via `useThree`/`useFrame`, portals its HUD through drei's `Html fullscreen`), `@jgengine/shell/replay/useSessionRecorder` (records an entity's pose into a `RecordingBuffer` every frame; drive an observer-cam ghost, scrubber, or kill-cam export from it). The detached spectator/photo cam itself is the `observer` camera rig (see Camera rigs above) — bind it to any entity or fixed point.
1361
-
1362
- ## World features
1363
-
1364
- Renderer-free world surface — query primitives, environment fields + weather + realm composition, survival meters/moodles, interactive building & terraform, the optional headless physics world, vehicles/mounts/racing, and spawn placement. Full surface: **[reference/world.md](reference/world.md)**.
1365
-
1366
- ## Turn-based & tactics (renderer-free)
1367
-
1368
- Pure-`core` primitives for turn-based, grid-tactics, and card games — every one is a stateful factory with matching pure math, and every stateful piece exposes `capture()`/`restore()` so it plugs straight into the snapshot store. Overlays and tile art are the shell's/game's job; these ship the logic.
1369
-
1370
- - **`turn/turnLoop` — `createTurnLoop(config)`.** An initiative machine over an ordered participant list with optional `phases` and per-turn action-economy `pools`. `advanceTurn()` walks the order (round++ on wrap) and **resets the entering participant's pools**; `advancePhase()` steps phases then rolls into the next turn. Pools are catalog data (`{ id, max, start? }`) — a single Slay-the-Spire energy pool or BG3's Action/Bonus/Movement/Reaction set, spent independently via `spend/canSpend/gain/refill`. `setOrder`/`addParticipant`/`removeParticipant` re-roll initiative without losing the active pointer. `config.onTurnStart?(participantId)`/`onTurnEnd?(participantId)` fire on every `advanceTurn()` transition (start also fires once for the initial participant at construction) — hang status-effect ticks, "your turn" banners, or AI-turn kickoff here instead of diffing `state()` between ticks yourself. `ctx.game.turn.loop(id, config?)` is the runtime-wired accessor: lazily creates (config required the first call) or returns the existing notify-wrapped loop for `id`, so a HUD bound to `ctx.subscribe` re-renders on every turn/phase/pool change with no separate store to wire.
1371
- - **`turn/commit` — `createCommitController({ mode })`**, also hosted at `turnLoop.commit`. Three commit modes: `immediate` (submit resolves now), `simultaneous` (sealed hidden submissions → `reveal()` once `allReady()`, deterministic order — Marvel Snap), and `rewind` (visible `pending()` → `rewind()` to discard or `commit()` to finalize).
1372
- - **`turn/intent` — `createIntentBoard()`.** A minimal per-participant "what will you do" board, lighter than a full commit round: `declare(participantId, { kind, magnitude?, targetId?, note? })` records one intent per participant (overwriting any prior undeclared one), `peek(participantId)` reads without clearing, `all()` lists every declared `[participantId, intent]` pair (for an enemy-intent HUD row, Slay-the-Spire style), `consume(participantId)` reads and clears in one call, `clear(participantId?)` clears one or everyone. Reach for this when you need visible declared-but-not-yet-resolved intents (telegraphed enemy actions) without the full simultaneous-reveal machinery of `turn/commit`.
1373
- - **`tactics/tacticalGrid` — `createTacticalGrid({ width, height, blocked?, diagonal?, world? })`.** Tile occupancy (one unit per tile), `reachable(from, budget)` flood-fill (respects walls + occupants), `path(from, to)` shortest route, and `push(id, dir, { distance, chain })` discrete knockback-to-tile — chained collisions transfer momentum through struck units (Into the Breach), or stop with a recorded `PushCollision` against `wall`/`edge`/another unit. `world: { origin: [x, z], tileSize }` (mirroring `navGrid`'s bounds+cellSize convention) turns on `worldToTile(x, z)` (world point → `Tile | null`, null outside the grid) and `tileToWorld(tile)` (a tile's world-space center) — the render/pointer-hit round trip between the tactics grid and the 3D scene; omit `world` for a grid used purely as abstract logic (both throw if called without it).
1374
- - **`tactics/predictiveQuery` — `predictAreaEffect`/`predictArcEffect`/`predictTiles`.** A "would-this-effect-hit" query for pre-commit overlays and enemy-intent telegraphs. It reuses the **exact** AoE/LoS targeting behind `ctx.scene.entity.effect` (`combat/effects` `resolveAreaTargets`) so the predicted target set matches what the effect would actually drain — without committing any state change.
1375
- - **`tactics/snapshot` — `createSnapshotStore()`.** Cheap, repeatable turn-undo: `register(id, slice)` any `capture()/restore()` slice (the grid, surfaces, and turn loop all qualify), then `capture()/restore()` a deep-cloned snapshot or use the `push()/pop()` undo stack. `deepClone` handles objects/arrays/Map/Set so a held snapshot is immune to later mutation.
1376
- - **`tactics/surface` — `createSurfaceLayer({ kinds, reactions })`.** A stateful tile surface layer with its own `tick(dt)` (timed surfaces decay + expire) and a **combination matrix** — `reactions` is data (`{ when: [a, b], result }`), so grease+fire→fire and water+lightning→electrified are catalog entries, not hard-coded. Distinct from terrain/water; drive its tick from `onTick`'s game-time `dt`.
1362
+ **Overflow is an error, not a style note.** `HudCanvas` measures every `HudPanel` against the viewport at runtime; offenders land in a `data-hud-overflow` attribute (and a console warning), and `bun run shoot <game> --device mobile` (or `both`) exits non-zero naming the escaping panels. A game is not mobile-done while shoot reports HUD OVERFLOW.
1377
1363
 
1378
- ## External data — `data/dataSource` and the dev proxy
1364
+ ## 5. Change the visual grammar
1379
1365
 
1380
- Renderer-free async-state primitives (`@jgengine/core/data`) for a game that reads a live external source (a leaderboard API, a session browser, remote config) — distinct from `ctx.game.store`/multiplayer, which are for the game's own authoritative state.
1366
+ Avoid making every element the same rounded translucent rectangle.
1381
1367
 
1382
- - **`createDataSource(load, options?)`** (`data/dataSource`) → `DataSource<T>` wraps one `load(signal)` async call as `{ status: "idle"|"loading"|"ready"|"error", data, error }`. `getState()` reads the current snapshot, `subscribe(listener)` fires on every change, `refresh({ force? })` re-runs `load` (de-duplicates a call already in flight unless `force`; aborts the prior call first when forced), `startPolling(intervalMs?)`/`stopPolling()` run `refresh` on an interval (`intervalMs` falls back to the one passed at construction; throws if neither is given), `dispose()` tears down polling and in-flight requests for good. Pass `options.clock` (`{ setInterval, clearInterval }`) to swap the timer source in tests.
1383
- - **`fetchJson<T>(url, options?)`** (`data/fetchJson`) — `fetch` + JSON-parse in one call; throws `HttpStatusError` (`status`, `statusText`, `url`) on a non-OK response and `JsonParseError` (`url`, `cause`) on unparsable JSON, so a `DataSource`'s `error` is always one of these two typed shapes, never a bare `Error`. `options.fetchImpl` swaps the fetch implementation for tests/SSR.
1384
- - **`createJsonDataSource<T>(url, options?)`** (`data/jsonDataSource`) — sugar combining the two above: a `DataSource<T>` whose `load` calls `fetchJson(url, options)`.
1385
- - **Dev proxy (`data/devProxy`)** — same-origin routing for external APIs during `bun dev` so browser CORS never blocks a game's `fetchJson` call against a third-party host. `parseDevProxyTable(raw)` parses a `VITE_JGENGINE_DEV_PROXY` env value (a JSON object of `{ routeName: "https://api.example.com" }`) into a `DevProxyTable`; `proxiedUrl(target, { dev?, table?, prefix? })` rewrites a `target` URL whose prefix matches a table entry into `/proxy/<routeName>/<rest>` (default prefix `/proxy`) when `dev` is true (defaults to `import.meta.env.DEV`) — else returns `target` unchanged, so the same call hits the real host in production. `apps/dev`'s `vite.config.ts` reads the same env var and wires a matching Vite server `proxy` entry per route (`changeOrigin: true`, strips the `/proxy/<routeName>` prefix) — set `VITE_JGENGINE_DEV_PROXY` once and both sides (the URL rewrite and the actual proxy route) agree.
1368
+ Use genre-appropriate structures:
1386
1369
 
1387
- ## Multiplayer and the backend seam
1370
+ - clipped corners
1371
+ - irregular silhouettes
1372
+ - image-backed frames
1373
+ - mechanical plates
1374
+ - radial interfaces
1375
+ - ribbons and tabs
1376
+ - gauges and meters
1377
+ - emblems and decorative corners
1378
+ - notches and edge anchors
1379
+ - diegetic objects
1380
+ - world-space prompts
1381
+ - asymmetrical compositions
1382
+ - masks, textures, layered borders, and strong focal elements
1388
1383
 
1389
- The transport/host/persistence seam `createWsBackend`, protocol codec, browser-safe authoritative host + router, WebRTC P2P, the Node/Convex/SQL adapters, presence, and save cadence. Full surface: **[reference/multiplayer.md](reference/multiplayer.md)**.
1384
+ Practical rule: no more than roughly 20% of a normal gameplay screen should resemble an ordinary web card or modal.
1390
1385
 
1391
- ## UI `@jgengine/react`
1386
+ Every persistent panel must justify why it exists, remains visible, has that shape, and occupies that position.
1392
1387
 
1393
- The React layer — `GameProvider`, the hooks table, headless className-passthrough primitives (incl. map components), the identity/chat/voice/social/drag-layer kits, the shadcn registry install path for visual HUD components (`npx shadcn@latest add https://jgengine.com/r/<name>.json`), the screen-layout rule, and the **UI quality bar** (required, not optional polish). Full surface: **[reference/ui-react.md](reference/ui-react.md)**.
1388
+ ## 6. Game UI primitives
1394
1389
 
1395
- ## Devtools F2 overlay and tunables
1390
+ Prefer small headless or lightly styled primitives over a giant universal design system. Useful concepts include:
1396
1391
 
1397
- `@jgengine/shell`'s `GamePlayerShell` mounts an F2-toggled debug overlay (`shell/src/devtools/DevtoolsOverlay.tsx`) over every game automatically — `defineGame({ devtools: false })` is the only way to turn the toggle off (default `true`). Five tabs:
1392
+ - `HudAnchor`
1393
+ - `StatReadout`
1394
+ - `Meter`
1395
+ - `ObjectiveTracker`
1396
+ - `ActionPrompt`
1397
+ - `Reticle`
1398
+ - `MinimapFrame`
1399
+ - `DialoguePlate`
1400
+ - `Countdown`
1401
+ - `BossBar`
1402
+ - `ItemPickup`
1403
+ - `DamageIndicator`
1404
+ - `PauseScreen`
1405
+ - `ResultsScreen`
1406
+ - `VirtualControlZone`
1407
+ - `ScreenTransition`
1398
1408
 
1399
- | Tab | Shows |
1400
- |-----|-------|
1401
- | Perf | fps, frame/sim ms, draw calls, triangles, entity/object counts, state notifies/s, registered probes |
1402
- | Tune | every discovered tunable — checklist grouped by source file or table export name; check one to control it live |
1403
- | Logs | captured `console.log`/`info`/`warn`/`error` |
1404
- | Net | observed backend round-trip latency (fed by `instrumentLatency`) |
1405
- | Keys | the game's `ActionCodesMap` bindings |
1409
+ A primitive should expose game-oriented choices such as shape, material, urgency, placement, hierarchy, entry motion, icon treatment, compactness, and diegetic-versus-overlay presentation.
1406
1410
 
1407
- **Tunables are zero-annotation.** Write plain code under `Games/<id>/src/**` no import, no wrapper and it's discoverable:
1411
+ Do not create a primitive whose only value is wrapping a `div` with border radius.
1408
1412
 
1409
- ```ts
1410
- // loop.ts — top-level export const, nothing else
1411
- export const GRAVITY = -22;
1412
- export const SKY_COLOR = "#87ceeb";
1413
- export const GOD_MODE = false;
1413
+ ## 7. Complete interaction states
1414
1414
 
1415
- // game/content.ts an exported flat table of numbers/booleans/colors
1416
- export const TUNING = { reach: 6, spawnRate: 0.4, fogColor: "#334455" };
1417
- ```
1415
+ Every interactive element needs intentional states:
1418
1416
 
1419
- The dev runner's Vite plugin, `tunableDiscoveryPlugin` (`@jgengine/core/devtools/transformTunables`, wired in `apps/dev/vite.config.ts`), rewrites each top-level `export const <number|boolean|"#hex">` literal to `export let` and binds it into the devtools registry as the module loads (`transformTunableExports` is the pure string transform underneath; `tunableModuleTable(id)` derives the table id from the file path, skipping `main.tsx` and `*.test.*`). Table exports need no transform at all — after each game module loads, the dev app calls `devtools.discover.scanModule(moduleExports)`, which walks every export's own properties for a flat plain-object table of numbers/booleans/`"#rrggbb"` strings.
1417
+ - rest
1418
+ - hover where applicable
1419
+ - keyboard/controller focus
1420
+ - pressed
1421
+ - selected
1422
+ - disabled
1423
+ - success
1424
+ - failure
1425
+ - warning
1420
1426
 
1421
- F2 Tune tab lists every discovered entry as a checklist, grouped by source file (top-level constants) or by table export name (object tables). Checking an entry hands it a live slider/toggle/color picker; unchecking resets it to its initial value. Kind is inferred from the value: `number` → slider, `boolean` → toggle, a `"#rgb"`/`"#rrggbb"`/`"#rrggbbaa"` string → color.
1427
+ Do not communicate all states with background-color changes alone. Use appropriate combinations of:
1422
1428
 
1423
- **Liveness.** An edit applies live wherever the code reads the constant/table entry at use time. A value captured once at init — passed into a function call, baked into worldgen — only picks up the new value on reload. Overrides persist in `localStorage` per game (key `jg-devtools:<game name>`) and are re-applied *before* `loop.onInit` runs, so even an init-baked constant respects its override after a refresh — *if* the read happens at or after `onInit`. A read that happens earlier than that (see below) never sees the override, reload or not.
1429
+ - scale compression
1430
+ - position shift
1431
+ - edge or glow response
1432
+ - mask movement
1433
+ - icon movement
1434
+ - text response
1435
+ - brief particles
1436
+ - sound
1437
+ - haptics when supported
1438
+ - controlled shake only when appropriate
1424
1439
 
1425
- **Default assumption: almost every gameplay number, boolean, and color is a tunable, not a hardcoded fact.** Walk speed, jump height, gravity, damage, cooldowns, spawn rates, drop chances, radii, durations, thresholds, multipliers, colors — if it's a scalar a designer would plausibly want to nudge while playing, it belongs in a place discovery can see (a top-level `export const`, or a direct scalar field on a catalog def object like `PlayerDef`/`EnemyDef`) — never buried as a bare literal inside a deeper nested object with no named export, and never computed once and thrown away. Treat "should this be tunable" as opt-out, not opt-in.
1440
+ Focus must look authored while remaining accessible. Menus should support keyboard/controller-style focus navigation when practical.
1426
1441
 
1427
- **Catalog-derived content must read fields live, not bake them at import time.** A common trap: a `content.ts` (or any module implementing `GameContextContent`) that loops over a catalog array *once at module scope* and copies scalar fields into a separately cached `Map`:
1442
+ ## Settings menu
1428
1443
 
1429
- ```ts
1430
- // WRONG — copies walkSpeed by value at import time, before devtools can even scan exports
1431
- const entityEntries = new Map<string, GameContextEntityEntry>();
1432
- for (const p of players) {
1433
- entityEntries.set(p.id, { stats: p.stats, movement: { poses: p.poses, walkSpeed: p.walkSpeed } });
1434
- }
1435
- export const content: GameContextContent = {
1436
- entityById: (id) => entityEntries.get(id) ?? null,
1437
- };
1438
- ```
1444
+ **Settings menu (themed, four layouts, no forced chrome).** The engine builds the whole menu for free — Sound (master + per-bus volume), Graphics (quality/dpr + shadows), Gameplay (FOV slider, default 40–120), Controls (per-action key rebinding, inline click-to-rebind, persisted) — from the game's `audio.buses` and `input` map. What it does **not** do is bolt a fixed gear onto every game: **there is no auto trigger.** You place the entry yourself so it lives *inline with your game's own UI*, never a stray corner overlay. Drop `<SettingsTrigger className=…>` (from `@jgengine/react`) anywhere in your HUD or menu — headless button, `className` for skin/placement, optional `children` to replace the default gear glyph, renders nothing when there's nothing to show. Or call `useSettings().open()` from your own control. Tune the menu via `defineGame({ settings })` (`GameSettingsConfig` from `@jgengine/core/settings/settingsModel`):
1439
1445
 
1440
- This runs during module import earlier than `discoverGameTunables`/override-application in `apps/dev/src/main.tsx`, and earlier than any `loop.onInit`. The catalog object (`p`) still gets live-mutated by devtools, but nothing ever re-reads it, so the baked `walkSpeed` is permanently stale: no F2 edit and no persisted override ever reaches gameplay, reload or not.
1446
+ - `variant: "panel" | "sheet" | "sidebar" | "fullscreen"` the layout + skin (default `panel`; `sheet` is the mobile bottom-sheet). All four are fixed-size (no shrink-to-content jitter) and read the game's `--jg-*` theme tokens, falling back to a neutral dark skin.
1447
+ - `actions: SettingsActionDef[]` — game-state actions (Restart, Quit to menu, …). They become the **first "Game" tab, shown before anything else** — the home for buttons that used to float over the HUD. Each: `{ id, label, kind?: "default"|"danger", description?, run(ctx) }`; the menu closes right after `run`.
1448
+ - `hideBindings: string[]` — input actions to drop from the rebindable Controls list. A game-state key like `restart` belongs in `actions`, not the rebind grid — hide it here so it stops showing up as a "rebindable" control.
1449
+ - `surface: "quick"` — additionally mount compact on-screen volume/graphics buttons. Omit for none. `settings: false` — off entirely.
1450
+ - `extra: GameSettingDef[]` — append rows to any category, built-in *or a brand-new one* named by `category` (any string). Each row: `{ id, label, category, kind: "slider"|"toggle"|"select", default, min?, max?, step?, options?, onChange?(value, ctx) }`.
1451
+ - `categories: SettingCategoryDef[]` — declare custom category tabs, or relabel/reorder built-ins (`{ id, label, order? }`).
1452
+ - `hide: SettingCategory[]` — drop built-in categories.
1441
1453
 
1442
- ```ts
1443
- // RIGHT — map ids to the catalog def itself; build the entry fresh on every lookup
1444
- const playersById = new Map(players.map((p) => [p.id, p]));
1445
- function entityById(id: string): GameContextEntityEntry | null {
1446
- const p = playersById.get(id);
1447
- return p === undefined ? null : { stats: p.stats, movement: { poses: p.poses, walkSpeed: p.walkSpeed } };
1448
- }
1449
- export const content: GameContextContent = { entityById };
1450
- ```
1454
+ **Game-state controls go in `actions`, never a floating button.** Restart/quit/new-game buttons stapled to the bottom of the HUD are the anti-pattern — declare them as `actions` (first Game tab) and place a `<SettingsTrigger>` inline. A contextual button on a win/lose *results* card is fine; a persistent game-state button pinned over live play is not.
1451
1455
 
1452
- Same shape, same call site the only change is *when* `p.walkSpeed` is read: at lookup time (each spawn) instead of once at import. Objects (`stats`, `receive`) are already reference-safe to pass through either way; this only matters for scalars (numbers/booleans/strings) copied out of a catalog def.
1456
+ **Present it any way you want.** `useSettings()` (`@jgengine/react`) returns the live controller `{ categories, actions, variant, surface, isOpen, open, close, setOpen }` so a game can drive its own pause-menu button, or render `categories`/`actions` (rows carry `value`/`set`/bounds, keybinds carry `rebind`/`reset`) entirely inside its own HUD. `useHasSettings()` gates a custom entry; `useSetting(id, fallback)` reads/writes one value. Set a slider's `min`/`max` explicitly — an omitted range collapses the thumb to 0/1.
1453
1457
 
1454
- **`tunable()` still exists an optional low-level primitive, not the recommended path.** Reach for it only when you need explicit bounds, an `options` select, or a change subscription that discovery can't infer:
1458
+ ## 8. Motion and game feel
1455
1459
 
1456
- ```ts
1457
- import { tunable } from "@jgengine/core/devtools/devtools";
1460
+ Add purposeful motion for:
1458
1461
 
1459
- const gravity = tunable("physics/gravity", -22, { min: -60, max: 0 });
1460
- ```
1462
+ - screen entry and exit
1463
+ - confirm and cancel
1464
+ - warnings
1465
+ - score increases
1466
+ - objective updates
1467
+ - damage
1468
+ - victory and failure
1469
+ - countdowns
1470
+ - pause
1471
+ - item pickup
1461
1472
 
1462
- Read `gravity.value` at use time (or `gravity.subscribe(listener)`) — never destructure once at module load. A `"group/label"` name (e.g. `"physics/gravity"`) groups the control under `group` in the Tune tab; `devtools.controls.register` is the same call underneath. Real example: `Games/voxel-mine/src/loop.ts` — `tunable("mining/reach", REACH, { min: 2, max: 16, step: 1 })`, read via a getter passed to `createEditorHandlers`.
1473
+ Motion should be brief, readable, interruptible when necessary, coordinated, consistent with the game’s art direction, and respectful of reduced-motion settings.
1463
1474
 
1464
- **Agent loop.** The overlay's "Copy report" button copies a JSON `DevtoolsSnapshot`; from a browser session an agent can instead call `window.__JG_DEVTOOLS.snapshot()` directly (or `snapshotDevtools()` from game code) for the same shape — frame stats, render sample, latency stats, captured logs, probe values, every registered control's current + initial value, and a `discovered` array (`id`, `kind`, `value`, `enabled`) covering every auto-discovered tunable whether or not it's enabled — a single call to check "is this actually working" without a screenshot. `window.__JG_DEVTOOLS.discover` is exposed directly too (`list`/`enable`/`disable`/`bind`/`scanTable`/`scanModule`/`clear`) so an agent can flip a discovered tunable on and read/write it from the console without touching the UI.
1475
+ Do not animate everything constantly. Motion communicates hierarchy, cause and effect, urgency, and state changes.
1465
1476
 
1466
- `devtools.probes.register("name", () => value)` (`@jgengine/core/devtools/devtools`) surfaces a game-specific gauge (entity count, queue depth, whatever) in both the Perf tab and the snapshot; call the returned unregister function to remove it.
1477
+ ## 9. Mobile controls are genre-authored
1467
1478
 
1468
- ## Assets real art from day one
1479
+ Shared input mechanics may remain shared. Their visual treatment and arrangement must match the game.
1469
1480
 
1470
- Squares as enemies, colored boxes as buildings, and a flat grid floor read as *broken*, not unfinished. The blueprint's **Asset plan** names the packs before the first edit; a pass does not end while any default-material primitive, unstyled ground plane, or debug grid is visible in the staged screenshot — and that includes 2D HUD art: a first-letter tile, emoji, or one generic shape reused per slot is a placeholder exactly like a graybox enemy. Primitive stand-ins are allowed only *mid-pass* as scaffolding.
1481
+ Examples:
1471
1482
 
1472
- **Sources** (CC0 — public domain, commercial use, no attribution — unless noted):
1483
+ **Driving**
1484
+ Steering region or wheel, accelerator, brake, handbrake, optional camera/map control.
1473
1485
 
1474
- | Source | What you get |
1475
- |--------|--------------|
1476
- | [Kenney.nl](https://kenney.nl) | 40,000+ CC0 assets: characters, buildings, nature, vehicles, weapons, UI, audio — the broadest single library |
1477
- | [Quaternius](https://quaternius.com) / [KayKit](https://kaylousberg.itch.io) | CC0 low-poly packs incl. **rigged + animated characters**: medieval, sci-fi, dungeons, animals, adventurers |
1478
- | [Poly Haven](https://polyhaven.com) / [ambientCG](https://ambientcg.com) | CC0 PBR textures, HDRIs, materials — the floor comes from here, never a flat color |
1479
- | [Poly Pizza](https://poly.pizza) | Search engine over thousands of CC0 low-poly models for one specific thing |
1480
- | [Game-Icons.net](https://game-icons.net) (CC BY 3.0 — credit it) / Kenney UI packs (CC0) | 4,000+ item/ability **icon silhouettes** — the registry `game-icon` item covers common HUD glyphs first |
1481
- | [itch.io CC0 3D tag](https://itch.io/game-assets/assets-cc0/tag-3d) / [OpenGameArt](https://opengameart.org) | Long tail — **check the license per asset**, CC0 filter first |
1482
- | [Mixamo](https://www.mixamo.com) | Free humanoid animations (Adobe license — fine for shipped games, not CC0) |
1483
- | Kenney audio / [freesound CC0 filter](https://freesound.org) | Hit sounds, UI clicks, ambience |
1486
+ **Stealth**
1487
+ Movement zone, sneak/crouch hold, contextual interaction, temporary map/schedule control.
1484
1488
 
1485
- **Rules:**
1489
+ **Shooter**
1490
+ Movement zone, aim region, fire/action cluster, weapon or ability controls.
1486
1491
 
1487
- 1. **One style family per game.** Kenney + Quaternius + KayKit low-poly mix fine; low-poly models on photoreal PBR ground reads broken. Name the family in the blueprint.
1488
- 2. **License discipline.** CC0 needs nothing; anything else gets a line in `src/game/assets-credits.md` (source, author, license). Never ship an asset you can't name the license of.
1489
- 3. **Wire through the engine seams.** GLB models live in the game's `src/game/assets.ts` render catalog keyed by catalog id; billboards via `entitySprites`, real meshes via `entityModels`/`objectModels` in `defineGame({...})`; ground/skies belong to the world layer. Catalog `model` fields reference asset keys — never file paths in game logic. Source models through **`@jgengine/assets`** (`buildCatalog({ basePath })` → resolve ids/aliases → urls); `pull` packs into your app's `public/models/` (extracts Kenney's shared `Textures/` alongside the GLBs so models render textured). Network-restricted: `pull` falls back through `--mirror <baseUrl>` / `JGENGINE_ASSETS_MIRROR`, and `--offline` fails fast — see the package README for the fallback order and add/import flow.
1490
- 4. **Coverage follows the content budget.** Every entity family, placed object, and held item maps to a real asset *before* the catalog entry ships. If the pack lacks a model, restyle the noun to one it has — rename the fantasy, don't ship a cube.
1491
- 5. **Scale/pivot sanity.** `@jgengine/assets` measures each model's footprint/center/`minY` at reindex and ships them on the catalog entry (`catalog.resolve(id).dims`); with `objectModels` anchor `"center"` (the default) the shell centers the footprint on the placement point and ground-snaps the lowest vertex — so `object.place(id, cellX, y, cellZ, { rotation })` renders centered + grounded with no pivot math and no `dimensions.ts`. Anchor `"origin"` opts back into the raw GLB origin. Check the first placement of each model against its catalog `footprint`; one wrong pivot repeated 100 times is a rebuild.
1492
- 6. **Item and ability icons are assets too.** Every hotbar/inventory/ability slot renders a real, distinct icon — the registry `game-icon` catalog (`iconForItemId`/`iconForAction`) or a Game-Icons/Kenney silhouette — per the UI quality bar's real-icons rule.
1492
+ **Puzzle/arcade**
1493
+ Direct drag, tap, swipe, paddle region, or discrete directions. Do not add a joystick without a gameplay reason.
1493
1494
 
1494
- ## Genre cheat sheet
1495
+ Requirements:
1495
1496
 
1496
- - **Voxel/crafting**: objects for blocks/machines, `voxel()`, `object.break`/`object.placeFromInventory`.
1497
- - **Tycoon/lab**: objects + `slotInventory`, `plots()`, configure via prompt → command.
1498
- - **Shooter**: `fireProjectile`/`settleProjectile`; grenades settle → `effect({ at, radius })`; `movement.poses`/`aim` + zoom modifier; `servers({ … })` + game-owned `server.mode`; loadout classes from commands.
1499
- - **MMO/RPG**: bounded stats + `leveling()` over a game XP curve; `tabTarget` → `cycleTarget`; handlers read `getTarget`; quests bound to `entity.died`/`inventory.added`; social party + `partyShare`; `server: "persistent"`.
1500
- - **All combat games**: react to `entity.died` (feed/leaderboard/score) — never poll HP.
1497
+ - never cover critical HUD information
1498
+ - use the game’s shape and material language
1499
+ - fade training labels after learning
1500
+ - consider thumb reach
1501
+ - preserve accessible target sizes
1502
+ - visually respond to activation
1503
+ - support optional scaling where appropriate
1501
1504
 
1502
- ## Anti-patterns
1505
+ Do not use one generic translucent controller across all games.
1503
1506
 
1504
- | Wrong | Right |
1505
- |-------|-------|
1506
- | Player tuning in `defineGame` | Entity catalog `movement` + stats |
1507
- | `behaviors: […]` on place/spawn | Catalog entry |
1508
- | Engine `weapon.fire` / `consumable.use` / `combat.*` | `item.use` + catalog `use` → game handler |
1509
- | `ItemUseInput.to` for targets | `getTarget(from)` in handlers |
1510
- | `effect({ to })` for gunshots | `fireProjectile` + `settleProjectile` |
1511
- | Polling HP in `onTick` for kills | `entity.died` event |
1512
- | `combat.lootTable` / `loot.enemy` | `onDeath` on the entity that died |
1513
- | Hand-rolled `Math.random()` loot in commands | `lootTable()` + `ctx.game.loot.roll` |
1514
- | Hand-rolled `xpForLevel`/`levelFromXp` | `game/progression` `curve()` + `leveling()` |
1515
- | Hardcoded shop arrays | `item.trade.shops` + `tradableAt` |
1516
- | Kit seeding via scattered `put`/`grant` | `applyLoadout` |
1517
- | Per-user quest state hand-rolled | `game.quest.register` + binds |
1518
- | `useKillFeed` / per-domain feed hooks | `useFeed({ action })` |
1519
- | Raw keys in game logic | `defineGame` input actions |
1520
- | Positioning inside `ui/components/` or on primitives (`CurrencyPill className="absolute …"`) | Screen wrappers in `GameUI.tsx` only |
1521
- | Game UI classes without `@source` in host CSS | `@source` entries for your game dirs + `node_modules/@jgengine/{shell,react}` |
1522
- | One file per catalog entry / per brand | Dense `<domain>/catalog.ts` |
1523
- | Convex mutations called from game code | `commands.run` through the `GameBackend` transport |
1524
- | Half a system: quest without tracker, cooldown without sweep, keybind never shown, stub "coming soon" modal | Finish the system end to end — or cut it whole (see `jgengine-newgame`) |
1525
- | Game-side workaround for a missing engine primitive | File the gap at github.com/Noisemaker111/jgengine/issues (or PR the primitive) and cut or scope the dependent system honestly |
1526
- | Game nouns in this skill | Engine primitives + placeholder ids only |
1507
+ ## 10. Progressive instruction
1527
1508
 
1528
- ## New-game definition of done
1509
+ Do not leave large control grids visible during gameplay.
1529
1510
 
1530
- This is a gate, not a suggestion — every box, in one pass (workflow: **`jgengine-newgame`** skill). "Compiles and the hooks are wired" is not done; a declared system with no UI, no feedback, or no way to exercise it is not done — finish the system or cut it whole.
1511
+ Prefer:
1531
1512
 
1532
- - [ ] `game.config.ts` (`defineGame` from `@jgengine/shell/defineGame`) + `index.tsx` (barrel) + `main.tsx` (standalone host) + `loop.ts` + `game/content.ts`
1533
- - [ ] Catalogs: `game/entities/<role>/catalog.ts`, `game/items/<domain>/catalog.ts`, `game/objects/catalog.ts`; loot tables beside their domain
1534
- - [ ] Entity `stats` + `receive` orders aligned on the same stat ids; `role` set (drives targeting + camera)
1535
- - [ ] `game/items/use-handlers.ts` registered in `onInit`; handlers read `getTarget`/`aim`, never a target input
1536
- - [ ] `game/loadouts.ts` + `applyLoadout` in `onNewPlayer` (gated on `isNew`)
1537
- - [ ] `game/quests/catalog.ts` + binds; if using xp/level, a game-owned curve fed to `game/progression` (`curve`/`leveling`) — **with their HUD/tracker, or cut**
1538
- - [ ] `onInit`: register handlers/loadouts/loot/quests, event listeners, feed binds, leaderboard tracks; `setupWorld`
1539
- - [ ] Player spawns with `id === ctx.player.userId`
1540
- - [ ] `game/ui/GameUI.tsx` owns layout; components use `@jgengine/react` hooks
1541
- - [ ] UI passes the **quality bar** above (contrast, scale, framing, genre fit) — not just hook wiring
1542
- - [ ] Camera tuned via `camera` in `defineGame({...})` — defaults untouched means the feel was never checked
1543
- - [ ] For an `environment()` world: a `<game>.world.test.ts` asserts `summarizeEnvironment(world)` (`@jgengine/core/world/environmentSummary`) is non-empty with the expected counts — the browserless scene-correctness gate
1544
- - [ ] HUD screenshotted over a staged `GameUiPreview` scenario and **judged by looking at the image** against the UI quality bar in [`reference/ui-react.md`](reference/ui-react.md) — the final human glance, not the verification loop
1545
- - [ ] Co-located bun tests for pure game math (curves, cooldowns, spawn logic)
1546
- - [ ] Multiplayer via adapter config only; no direct backend calls
1513
+ - contextual prompts
1514
+ - brief onboarding
1515
+ - first-use hints
1516
+ - a controls screen
1517
+ - pause-menu reference
1518
+ - icons attached to actions
1519
+ - progressive disclosure
1547
1520
 
1548
- ## Quick reference
1521
+ Desktop keyboard legends must not appear on touch devices. Prompts should appear near the relevant action, object, or HUD region and clear when no longer useful.
1549
1522
 
1550
- ```
1551
- defineGame (shell) engine fields (assets, world, physics, inventories, input, server, save, time, feed, multiplayer)
1552
- + presentation fields (content, loop, GameUI, camera, environment, shadows, movement, devtools, …) in one call — smart defaults fill the rest
1553
- defineGame (core) the underlying engine-only primitive: assets, world, physics, inventories, input, server, save, time, feed, multiplayer, loop
1554
- PlayableGame { game, content, loop, GameUI, camera, … } — the runner contract `defineGame` (shell) returns
1555
- GameContext ctx.scene / ctx.game / ctx.player / ctx.item / ctx.camera / ctx.input + subscribe/version
1556
- scene.object place, remove, move, rotate, at, setVisual (per-instance ObjectVisual: scale/color/opacity override)
1557
- scene.entity spawn (anchor/offset), despawn, setPose, update; stats; targeting; effects;
1558
- projectiles (object-aware raycasts); spatial queries (opt-in grid broadphase)
1559
- entity.stats get / set / delta — bounded stats (health, mana, xp, level) on instances
1560
- progression game/progression — curve() / leveling() over bounded xp/level stats
1561
- item.use catalog `use` → GameContext handler; no input.to
1562
- effects drain-signed magnitudes; receive.<effect>.order; AoE = effect + at/radius/los
1563
- projectiles willHit → fire → settle; ballistic via weapon.projectile
1564
- death onDeath (reason-aware drops/command), entity.died, auto kill attribution + drop grant
1565
- game.loot register / has / roll / grantToPlayer (lootTable() = pure factory)
1566
- game.trade canBuy / canSell / buy / sell / tradableAt
1567
- game.quest register, accept…turnIn, bind(entity.died | inventory.added), declarative rewards
1568
- game.social friends (persisted, requests listable), party (ephemeral, invites listable), presence, emotes (nearby broadcast), worldInvites (accept → join target)
1569
- game.chat send / whisper / history / register — global/party/proximity channels, rate-limited, mute via blocked set
1570
- game.roster capture / release / list / setEquipped — persisted owned-creature roster
1571
- game.store/cards/turn store: keyed reactive slot; cards.pile(id): lazy CardPile; turn.loop(id): lazy TurnLoop
1572
- game.events/feed/leaderboard on / bind+push+recent / track+increment+getTop
1573
- devtools F2 overlay (Perf/Tune/Logs/Net/Keys); zero-annotation — top-level export const number/boolean/color + exported flat tables auto-discover into Tune; tunable() is the optional low-level escape hatch; snapshotDevtools()/window.__JG_DEVTOOLS.snapshot() → DevtoolsSnapshot (+ discovered[]); probes.register(name, read) adds a Perf gauge
1574
- applyLoadout all-or-nothing kit seeding per userId
1575
- player.movement pose (hitboxes) + aim (zoom modifier)
1576
- player.motion impulse / setVerticalVelocity / setY — vertical-motion seam into the shell's frame driver
1577
- player.possession own/disown/owns/listOwned + active + possess — control-swap, rebinds shell camera
1578
- player.cosmetics register + apply/equip + get — per-player appearance slots, no gameplay effect
1579
- scene.entity.form register + shapeshift/revert + active/abilities — movement+ability+mesh bundle, game-time duration
1580
- proximityPrompt { radius, display: {kind}, invoke } — one float-UI primitive
1581
- skillCheck/qte evaluateSkillCheck (moving zone + window) / evaluateQteSequence (timed steps)
1582
- captureCheck captureChance / rollCapture — hp% + catchPower → probability
1583
- dialogue check DialogueChoice.check (roll vs DC + advantage/disadvantage) → onSuccess/onFailure
1584
- world features biomes / voxel / plots / tilemap / flat descriptors
1585
- physics/physicsWorld optional headless rigid-body sim (PhysicsWorld) — not the defineGame physics field (gravity/jumpVelocity, honored by the kinematics controller); bodies are box (halfExtents) or sphere (radius)
1586
- physics/ballisticSweep createBallisticSweep(world) → arc-vs-body hit test; wire into ProjectileSystemDeps.sweepBallistic
1587
- anim/easing lerp/clamp01/smoothstep/tween/timedProgress + easeIn/Out/InOut Quad/Cubic + easeOutBack/Elastic — pure 0..1 tweening math
1588
- data/dataSource createDataSource/createJsonDataSource — idle/loading/ready/error async state + polling; data/fetchJson + data/devProxy for CORS-safe dev fetches
1589
- audio/audioFalloff computeFalloffGain / resolveEmitterGain — pure distance→gain curve; shell plays it
1590
- time/beatClock createBeatClock (BPM ticks) + createBeatInputBuffer (buffered action → next beat)
1591
- ws/voiceChannel createVoiceChannelRouter — positional falloff + simultaneous non-positional channels
1592
- multiplayer/identity AuthSession + sessionPlayer + resolveGuestSession — Clerk/better-auth via react structural adapters
1593
- multiplayer/chatContract ChatTransport (hooks) / ChatSync (callbacks) — ws + convex bindings, local for dev
1594
- multiplayer/voiceContract VoiceTransport (join/leave/publish/subscribers) + createPushToTalk — media plane host-supplied
1595
- GameBackend { transport, feeds?, presence? } — Convex is one adapter (createConvexBackend)
1596
- adapter kinds offline / ws / convex / socketIo / p2p / lan (+ fly({app}) ws sugar) — runtime/adapter
1597
- ws/pipe TransportPipe/TransportPipeFactory — any bidirectional string channel (webSocketPipe default)
1598
- ws/host, ws/hostRouter browser-safe createGameHost + createHostRouter/loopbackPipe (node re-exports both)
1599
- ws/peer createPeerHost/createPeerGuest — WebRTC P2P, host tab authoritative, copy-paste signal codes
1600
- ws/socketIoPipe, node/socketIoServer socketIoPipe/createSocketIoBackend + attachGameSocketIoServer
1601
- @jgengine/react GameProvider + hooks + headless primitives (incl. identity/chat/voice/social kits); layout only in GameUI.tsx
1602
- ```
1523
+ ## 11. Reference directions for flagship games
1603
1524
 
1604
- Engine ships verbs and primitives. Your game ships nouns.
1525
+ ### Clockwork Heist
1605
1526
 
1606
- # jgengine-api UI @jgengine/react
1527
+ Use gentleman-thief mechanical field-kit language: watch geometry, midnight enamel, aged brass, ivory paper, engraved labels, mechanical shutters, and an authored schedule timeline. The timer should feel like a clock. The schedule should be an on-demand pocket-watch or dossier panel. Touch controls should use brass/enamel construction. Restart belongs in pause, not permanently over the world.
1607
1528
 
1608
- Reference module for the [`jgengine-api`](../SKILL.md) skill. Load this when you need the React UI layer. The **UI quality bar** section at the bottom is how the HUD must look and behave — required, not optional polish.
1529
+ ### Canyon Chase
1609
1530
 
1610
- ```tsx
1611
- import { GameProvider, useSceneEntities, HealthBar } from "@jgengine/react";
1531
+ Use desert pursuit language: battered dashboard, analog instruments, radio display, route strip, warning lamps, and road-sign typography. Target gap should read as a pursuit gauge. Border progress should resemble an odometer, route strip, or mile marker. Driving controls should feel like steering, throttle, brake, and handbrake—not generic circles.
1612
1532
 
1613
- <GameProvider context={ctx}>…</GameProvider>
1614
- ```
1533
+ ### Brick Breaker
1615
1534
 
1616
- Import provider, hooks, and headless components from the package root `@jgengine/react` (a barrel re-export). The per-file subpaths (`@jgengine/react/provider`, `/hooks`, `/components`) resolve the same symbols if you prefer them.
1617
-
1618
- All hooks bind through the ctx change signal (`ctx.subscribe`/`ctx.version`):
1619
-
1620
- | Hook | Returns |
1621
- |------|---------|
1622
- | `useGame()` / `usePlayer()` | `{ commands, events }` / `{ userId, isNew }` |
1623
- | `useSceneEntities()` / `useSceneObjects()` | live snapshots for rendering |
1624
- | `useWorldItems()` / `useNearestWorldItem(radius)` | ground-loot snapshots / nearest pickup for a HUD prompt |
1625
- | `useEntityStat(instanceId, statId)` | `StatValue \| null` |
1626
- | `useTarget(fromId)` | locked instanceId \| null |
1627
- | `useInventory(id)` / `useCurrency(id)` | slots / balance |
1628
- | `useFeed({ action, limit? })` | recent entries — kills, loot, any action |
1629
- | `useQuestJournal()` | active quests + objective progress |
1630
- | `useFriends()` / `useParty()` / `usePresence(userId)` / `useWorldInvites()` | social panels |
1631
- | `useFriendRequests()` / `usePartyInvites()` | pending inbound requests/invites for the local player |
1632
- | `useWorldBrowser({ fetchSessions, filter?, limit?, refreshMs? })` | polls a host-supplied fetcher (e.g. `createWsBackend().browse`) through matchmaking's `browseSessions` |
1633
- | `useSession()` / `useAuthedPlayer({ guestSeed? })` | auth session from `<GameIdentityProvider>` / the `{ userId, isNew }` player seam for `createGameContext` |
1634
- | `useChat(channelId, { limit? })` | local-player-filtered recent messages from `ctx.game.chat` |
1635
- | `useVoice({ transport?, channelId?, mode?, resolveRoutes? })` | mic capture + PTT + voice-channel roster over the `VoiceTransport` seam |
1636
- | `useRoster(userId?)` | owned/captured roster entries for a user (defaults to the local player) |
1637
- | `useLeaderboard(stat, { scope, limit? })` | `{ userId, value }[]` |
1638
- | `useActivePrompt(prompts)` | nearest proximity prompt |
1639
- | `useGameClock()` | clock snapshot (`now`, `paused`, `speed`, `calendar`) + `controls` (pause/play/setSpeed) |
1640
- | `useLocalPlayerDead()` / `localPlayerEntity(entities, userId)` | death-screen gating; local player from a snapshot |
1641
- | `useMarkers(markerSet)` / `useFog(fogField)` | live map-marker list / fog-cell snapshot (bind a core `MarkerSet`/`FogField`) |
1642
- | `useGameStore()` | raw store handle — escape hatch under the typed hooks |
1643
- | `useEngineState(store)` / `useEngineStore(store, selector)` / `useEngineEvent(store, event, handler)` | bind/select/subscribe against any `ReadableEngineStore<TState>` / `EventfulEngineStore<TEventMap>` — the escape hatch below `useGameStore()` for state that isn't wired into a typed hook yet |
1644
- | `useHeldKeys()` | `(code: string) => boolean` — raw window keydown/keyup/blur-backed held-key predicate, no `ctx` needed; the primitive `useAxisChannel` and the shell's own movement sampling are built on |
1645
- | `useAxisChannel(config: AxisChannelConfig)` | `{ channel: AxisChannel, isDown }` — wires `useHeldKeys` into a fresh `AxisChannel` (`@jgengine/core/input/axisInput`) for a per-frame `channel.sample(dt, isDown)`; a driving/twin-stick HUD or custom control scheme reads analog throttle/steer without touching `window` directly |
1646
-
1647
- Import hooks from `@jgengine/react/hooks`, components from `@jgengine/react/components`, `GameProvider` from `@jgengine/react/provider` (the package uses deep paths like core). `useEngineState`, `ReadableEngineStore`, `useEngineStore`, `useEngineEvent` also ship from the main `@jgengine/react` entry point (defined in `@jgengine/react/engineStore`, re-exported at the package root) — no deep import required.
1648
-
1649
- Headless components (className passthrough, no baked-in styling): `SlotGrid`, `HealthBar` (+ `fillClassName`), `CurrencyPill`, `ProximityPrompt`, `Screen`, `KeybindRow`, `DialogueBox` (+ `lineClassName`/`speakerClassName`/`choicesClassName`/`choiceClassName`/`checkClassName`, `rollCheck`-gated choices), `SkillCheckBar` (+ `trackClassName`/`zoneClassName`/`markerClassName`), `QteTrack` (+ `stepClassName`/`activeClassName`/`doneClassName`), `CaptureOdds` (+ `fillClassName`), `ToastStack`, `DeathScreen`, `LevelUpFlash`. Map components (bind a core `MarkerSet`/`FogField`, `kindStyles` palette overridable): `Minimap` (framed circular player-centered map — fog + markers + facing arrow, optional baked terrain `background`+`mapBounds`), `Compass` (facing strip with cardinals + marker pips), `WorldMap` (full-bounds top-down overlay). Not yet implemented: `useServer`, `useDialogue`.
1650
- **Identity (`@jgengine/react/identity`)** — `<GameIdentityProvider source={…}>` + `useSession()`. Sources: `clerkIdentity({ isLoaded, isSignedIn, user })` maps Clerk's `useUser()` shape, `betterAuthIdentity({ data, isPending })` maps better-auth's `useSession()` shape (both pure structural mappers — no SDK imports, one line at the call site), `guestIdentity(seed?)` for local/dev. Gate UI with `<RequireSession fallback loading>`; `<UserBadge>` / `<SignOutButton>` are headless like everything else. `useAuthedPlayer({ guestSeed? })` returns the `{ userId, isNew }` to hand `createGameContext` — feed the player seam from the session instead of hand-picking a userId.
1651
- **Chat (`@jgengine/react/chat`)** — headless `<ChatPanel>` (tabs + log + input composition with internal active-channel state), or compose `<ChannelTabs active onSelect>`, `<ChatLog channelId>` (auto-scrolls, `renderMessage` override), `<ChatInput channelId onSent onRejected>` yourself. All drive `ctx.game.chat` through `useChat`. `chatTransportFromSync(sync)` lifts a callback-style `ChatSync` (e.g. `createWsBackend(...).chatSyncFor(serverId)`) into the hook-shaped `ChatTransport` for remote chat.
1652
- **Voice (`@jgengine/react/voice`)** — `useVoice()` once per channel: `getUserMedia` mic capture (`requestMic()`, tracks gated by transmission), push-to-talk via `createPushToTalk` (hold/toggle/openMic + mute), roster from `VoiceTransport.subscribers`, and per-speaker `gainFor(userId)` when you pass `resolveRoutes: () => router.resolveRoutes(myUserId)` from `@jgengine/ws/voiceChannel`. Hand the returned state to the headless `<PushToTalkButton voice>`, `<MicToggle voice>`, `<SpeakingIndicator voice userId>`, `<VoiceRoster voice>`.
1653
- **Social (`@jgengine/react/social`)** — the headless social kit over `ctx.game.social`: friends (`<FriendsList>`, `<FriendRow>`, `<PresenceDot>`, `<AddFriendButton toUserId>`, `<FriendRequestsList>` with accept/decline), party (`<PartyFrame>`, `<PartyMemberRow>`, `<PartyInviteToast>`, `<LeavePartyButton>`), worlds (`<WorldBrowser listings onJoin>`, `<JoinByCode onJoin>` — normalizes codes, `<QuickMatchButton listings filter?>`, `<InviteToWorldButton toUserId target>`, `<WorldInviteToast onAccepted>` — hands you the `{ serverId, joinCode? }` join target), and `<EmoteWheel emotes>` over `emotes.play`. All className-passthrough with `data-*` hooks and `renderX` overrides; the `social-hub` demo in `apps/dev` (`?game=social-hub`) composes the whole kit.
1654
- **Drag/rotate/drop/snap gesture layer** (`@jgengine/react/dragLayer`) — a 2-D UI-space gesture layer over the card/shaped-grid primitives, distinct from 3-D world drag. `useDragLayer<T>({ onDrop })` owns pointer-follow drag state (begin/rotate/setTarget/end); pair it with the headless, className-passthrough `DraggableCard` (right-click rotates), `DropZone` (reports the snapped `cellFromPoint` cell + active state), and `DragGhost` (a pointer-anchored preview). Drop resolution and overlap validation stay the game's job via `canPlace`/`placeShaped` from `inventory/shapedGrid` — Balatro hand→play drags, Backpack Hero grid placement, Slay-the-Spire card-onto-enemy targeting.
1655
-
1656
- **Layout rule:** all **screen** positioning (`absolute`, `inset-*`, grid zones, flex regions) lives on wrappers inside `ui/GameUI.tsx` only. `ui/components/` files are content + hooks only — internal `relative`/`absolute` for bar overlays or slot badges inside a component is fine; never anchor a component to the viewport from a child file. Pass `className` to primitives for **visual** styling (colors, borders, size), not screen placement.
1657
-
1658
- **Tailwind sources:** add `@source` entries in your CSS for your game source dirs plus `node_modules/@jgengine/shell` and `node_modules/@jgengine/react`. Without them, classes used in dynamically imported game code are **not generated** — layout wrappers in `GameUI.tsx` silently fail and every HUD cluster stacks in one corner.
1659
-
1660
- ## Visual HUD via the shadcn registry
1661
-
1662
- Styled HUD components are **copy-in code**, not npm exports — install them from the JGengine registry with the shadcn CLI:
1535
+ Use arcade-cabinet language: bezel framing, CRT/vector treatment, arcade numerical readouts, attract mode, launch feedback, and brief level overlays that clear before play. Pause and results should feel like cabinet states, not web dialogs.
1663
1536
 
1664
- ```sh
1665
- npx shadcn@latest add https://jgengine.com/r/entity-vital-bar.json
1666
- ```
1537
+ These games must remain structurally distinct, not recolors of one component set.
1667
1538
 
1668
- The component lands in your game's `components/ui/`, themes through `--jg-*` CSS variables, and reads engine data through `@jgengine/react/hooks`. Health bar hookup, end to end:
1539
+ ## 12. Accessibility and performance
1669
1540
 
1670
- ```tsx
1671
- import { usePlayer } from "@jgengine/react/hooks";
1672
- import { EntityVitalBar } from "@/components/ui/entity-vital-bar";
1541
+ Maintain:
1673
1542
 
1674
- const { userId } = usePlayer();
1675
- return <EntityVitalBar instanceId={userId} statId="health" />;
1676
- ```
1543
+ - sufficient contrast
1544
+ - readable text size
1545
+ - keyboard navigation
1546
+ - controller navigation where available
1547
+ - reduced-motion support
1548
+ - visible authored focus
1549
+ - touch target sizing
1550
+ - semantic labels where practical
1551
+ - responsive scaling
1552
+ - reasonable DOM and animation performance
1677
1553
 
1678
- The full HUD catalog ships as registry items — vitals, slots, feedback, meters, panels, screens, reticles, plus the `game-icon` glyph catalog (`iconForItemId`/`iconForAction`), with engine-bound `Entity`/`Ability`/`Journal`/`Feed`/`Wallet` variants wired to the hooks. Where a registry item exists, prefer it — and never hand-roll a gray-box version of a component the registry already ships.
1554
+ Use blur, masks, textures, filters, and full-screen effects carefully, especially on mobile.
1679
1555
 
1680
- ### UI quality bar (required — not optional polish)
1556
+ ## 13. Screenshot verification is mandatory
1681
1557
 
1682
- Headless primitives mean **you** ship the visual design. Functional wiring alone is not shippable UI. Judge against staged screenshots, never your mental model of the code.
1558
+ Capture and inspect meaningful states:
1683
1559
 
1684
- **See what you ship.** `@jgengine/shell`'s `GameUiPreview` renders your `GameUI` over a staged `GameContext` (ticks run, hostile targeted, first ability fired) with no gameplay or backend — mount it on a dev route, screenshot it, and judge the image before calling any HUD work done; pass a custom `scenario` to stage richer states (open modals, low health, active quest). Type-green says nothing about whether the HUD renders.
1560
+ - desktop title screen
1561
+ - desktop gameplay
1562
+ - mobile landscape
1563
+ - mobile portrait where supported
1564
+ - pause
1565
+ - victory or failure
1566
+ - an interaction prompt
1567
+ - touch controls in active use
1685
1568
 
1686
- | Requirement | Minimum |
1687
- |-------------|---------|
1688
- | **Contrast** | HUD text and borders readable on the game's scene background — never bare `text-stone-400` on near-black without a panel |
1689
- | **Scale** | Primary HUD (unit frames, hotbar slots, menu buttons) ≥ 48px touch targets; body text ≥ `text-sm` (12px); key labels never below 11px |
1690
- | **Distinct construction** | Unit frame, hotbar, currency, quests, and toasts must not share one card style — same `rounded border bg-stone-900/80 p-3` on everything reads identical and cheap |
1691
- | **Real icons** | Every item/ability/hotbar slot shows a real, distinct silhouette or sprite (registry `game-icon`, asset-pack sprite) — never a gray box, first letter, emoji, or one generic shape reused everywhere |
1692
- | **Hotbar / slots** | Icon per ability; keybind badge on the slot corner; hover/active state; empty slots visually distinct |
1693
- | **Unit frames** | Name + level + labeled bars with numeric values; health/mana/resource colors genre-appropriate |
1694
- | **Layout** | No overlapping anchors; reserve space for frames that appear conditionally (target, quest log) |
1695
- | **Panels** | Modal/slide panels: title, close control, section headers, consistent chrome with the HUD |
1696
- | **Feedback** | Errors, cooldowns, and empty actions surface to the player (toast, dim, shake) — not `console.warn` only. Error text is ephemeral floating combat text, never a bordered toast card |
1569
+ Inspect for:
1697
1570
 
1698
- **Genre fit:** MMO/RPG → ornate dark panels, gold accents, portrait + bars, action bar with icons. Shooter → crosshair + ammo + ability cooldowns. Tycoon → resource pills + build menus. Match the game's fantasy; do not ship debug-gray placeholders.
1571
+ - overlap and clipping
1572
+ - weak contrast
1573
+ - unreadable scale
1574
+ - excessive cards
1575
+ - website-like composition
1576
+ - broken safe areas
1577
+ - conflicting hierarchy
1578
+ - browser chrome interference
1579
+ - poor thumb reach
1580
+ - keyboard instructions on touch devices
1581
+ - inconsistent art direction
1699
1582
 
1700
- **Panel vs frameless.** Modal/panel chrome (backdrop + bordered window) is for on-demand windows only: backpack/bags, combat log/chat feed, social window. Everything persistent stays frameless — typography, bars, icons, shadows, no enclosing card: player/target/party frames, action bar, quest tracker, currency, voice cluster, floating combat text, world VFX. Backpack is the only bag UI (no hotbar inside it); character sheet holds equipment + stats; the abilities page lists catalog abilities with costs/cooldowns/keybinds, not a hotbar duplicate.
1583
+ Revise after inspection. Typechecking is not visual proof.
1701
1584
 
1702
- **Keybinds are badges on their control.** Every toggle and hotbar slot shows its binding on itself (slot corner, toggle button) — never a persistent "WASD to move / E to interact" legend pinned to the screen; if a control needs explaining, badge it or use a proximity prompt that fades. Labels derive from the game's `keybinds.ts` via `actionLabel(keybinds, action)` — hardcoded key strings drift. Register actions in `defineGame.input`, wire through `game.commands` (never UI click handlers only), and read the binding table once before shipping: **one key, one action**. Panel toggles follow the `ui.openBackpack`-style command pattern with state in `ctx.game.store` (`useGameStore`), not a hand-rolled module store.
1585
+ ## 14. Rejection criteria
1703
1586
 
1704
- **Action bar slot states all four, visually:** ready (full color), cooldown (dim + sweep + numeric timer), no-resource (red tint/desaturate, cost checked before press), just-cast (brief bright ring flash ~200ms). Cooldown data lives in game code; UI reads it.
1587
+ Require revision when any of these are true:
1705
1588
 
1706
- **Combat feedback:** bolts/bullets are `fireProjectile` + delayed `settleProjectile` so they visibly travel — never `effect({ to })`; instant heals flash the unit-frame bar; out-of-range/oom is floating text that fades, no bordered toast.
1589
+ - normal site navigation remains visible during active gameplay
1590
+ - the player must scroll the page to use the game
1591
+ - the game is presented inside a normal content card
1592
+ - essential HUD is covered by touch controls
1593
+ - controls overlap each other
1594
+ - keyboard instructions appear on touch devices
1595
+ - large instruction panels remain visible during gameplay
1596
+ - more than three ordinary card-like panels are persistently visible
1597
+ - the main action looks like a standard website button
1598
+ - pause resembles a generic website modal
1599
+ - victory/failure is only text plus restart
1600
+ - restart is permanently visible without a gameplay reason
1601
+ - menu elements lack pressed, focused, selected, or disabled states
1602
+ - UI changes have no transition or feedback
1603
+ - generic virtual controls are used without genre adaptation
1604
+ - the theme only changes colors and fonts
1605
+ - unrelated UI elements have equal visual weight
1606
+ - mobile portrait technically fits but is not intentionally composed
1607
+ - important UI sits beneath safe areas
1608
+ - ordinary document flow determines the HUD layout
1609
+ - generic default styling is used because no art direction was written
1707
1610
 
1708
- **Mobile / touch:** HUD fits a 390px portrait viewport with no horizontal overflow (`min()`/viewport units once compact); `useDisplayProfile().compact` (`@jgengine/react/display`) collapses side panels into a slim top bar; keep the bottom ~180px clear for the engine's touch dock on `coarsePointer`; never render key badges/legends on touch; non-interactive wrappers stay `pointer-events-none` (only real controls opt in with `pointer-events-auto`); ≥48px touch targets always.
1611
+ ## 15. Compact implementation API appendix
1709
1612
 
1710
- **Social HUD:** build from the headless kits (`@jgengine/react/social`, `/chat`, `/voice`, `/identity`), never hand-rolled lists. Chat is a corner-anchored panel (channel tabs, log, input; sender names tinted apart from bodies); invite toasts are ephemeral with accept/decline that expire with the invite; presence is a dot (`data-online`), not a word; push-to-talk badges its keybind and visibly transmits (`data-transmitting`), speaking players glow on their party row; emote wheel appears on hold-key and fades. Every social button drives an engine verb (`social.friends.request`, `party.accept`, `worldInvites.accept` join) — one that only mutates local UI state is half a system.
1613
+ Use the main `jgengine` skill for the authoritative engine API routing. The React package exposes `GameProvider`, hooks, and headless primitives from `@jgengine/react` and its documented subpaths. Common hooks include player/game state, entities, stats, inventory, quests, prompts, clocks, markers, fog, and engine stores. The shell provides `GamePlayerShell`, input integration, devtools, and `GameUiPreview`.
1614
+
1615
+ Use these APIs to bind state; do not let API wiring dictate visual composition. Keybind labels should derive from the game’s binding table, and UI actions should dispatch through game commands rather than existing only as click handlers.
1711
1616
 
1712
- **Camera feel is part of the HUD pass.** The shell's orbit camera (left-drag orbit, scroll zoom, tap = primary ability, camera-relative WASD) tunes per game via the `camera` field of `defineGame({...})` (`minDistance`, `maxDistance`, `targetHeight`, `rotateSpeed`, `zoomSpeed`, `dampingFactor`, smoothing) — defaults untouched means the feel was never checked; never hardcode camera position in `onTick` while a rig is active.
1617
+ Headless components are not finished design. They are behavior and accessibility seams that the game must art-direct.
1713
1618
 
1714
- **Shared chrome:** extract repeated panel/slot styles into `ui/<theme>.ts` or `ui/components/<Frame>.tsx` — do not copy-paste three classes per file.
1619
+ ## Definition of done
1715
1620
 
1716
- **Self-check before calling UI done** (against actual staged screenshots):
1621
+ UI work is complete only when:
1717
1622
 
1718
- - [ ] Screenshot at 1080p: can you read every label without squinting?
1719
- - [ ] Could you mistake the unit frame and hotbar for the same component? If yes, redo.
1720
- - [ ] Every toggle shows its key on its own control — and no persistent controls legend anywhere?
1721
- - [ ] Every item/ability slot shows a real, distinct icon — no gray boxes, letters, or emoji?
1722
- - [ ] Do bolts visibly travel before damage lands?
1723
- - [ ] Only on-demand windows (backpack, log, social) have panel borders?
1724
- - [ ] Every declared system has its UI end state — quest → tracker, cooldown → sweep + timer, error floating text? A wired hook with no visual is half a system: finish it or cut it (see `jgengine-newgame`).
1725
- - [ ] Does the staged shot show the HUD *working* (target locked, cooldown mid-sweep, tracker populated), not resting-empty?
1726
- - [ ] Would a player think this is intentional art direction? If it looks like a debug build, it ships nothing.
1623
+ - the game owns the viewport
1624
+ - site chrome is absent during active play
1625
+ - no document scrolling is required
1626
+ - HUD and control layers have clear responsibilities
1627
+ - mobile controls do not cover critical content
1628
+ - touch controls match the genre and game identity
1629
+ - title, pause, and results screens feel authored
1630
+ - interaction states and transitions are present
1631
+ - screenshots have been reviewed and revised
1632
+ - the implementation remains accessible and performant
1633
+ - future generated UI is explicitly prevented from falling back to generic website-card styling