From c92a49adca385d9e4176a21ff99fd89be84ddddb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 16:51:33 +0000 Subject: [PATCH 1/2] Skills audit fixes: BOMs, mojibake, merge contradiction, retire jgengine-procedural, unify skill lists Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JuehpjvwcXpPYqy9yM9Hza --- .claude/skills/game/SKILL.md | 12 +- .claude/skills/harvest-full-game/SKILL.md | 20 +-- .claude/skills/harvest-game/SKILL.md | 22 ++-- .claude/skills/harvest/SKILL.md | 2 +- .claude/skills/jgengine-assets/SKILL.md | 2 +- .claude/skills/jgengine-assets/api.md | 11 +- .claude/skills/jgengine-combat/SKILL.md | 2 +- .claude/skills/jgengine-combat/api.md | 4 + .claude/skills/jgengine-combat/reference.md | 62 ++++----- .claude/skills/jgengine-editor/api.md | 49 ++++--- .claude/skills/jgengine-gameplay/SKILL.md | 6 +- .claude/skills/jgengine-gameplay/api.md | 38 +++++- .../skills/jgengine-gameplay/capabilities.md | 48 +------ .claude/skills/jgengine-multiplayer/SKILL.md | 4 +- .claude/skills/jgengine-multiplayer/api.md | 101 ++++++++++---- .../skills/jgengine-multiplayer/reference.md | 54 ++++---- .claude/skills/jgengine-procedural/SKILL.md | 13 -- .claude/skills/jgengine-procedural/api.md | 34 ----- .../jgengine-procedural/capabilities.md | 13 -- .../skills/jgengine-procedural/reference.md | 69 ---------- .claude/skills/jgengine-ui/api.md | 123 ++++++++++++------ .claude/skills/jgengine-ui/capabilities.md | 4 - .claude/skills/jgengine-world/SKILL.md | 2 +- .claude/skills/jgengine-world/api.md | 22 +++- .claude/skills/jgengine-world/capabilities.md | 8 +- .claude/skills/jgengine-world/reference.md | 110 ++++++++-------- .claude/skills/jgengine/SKILL.md | 5 +- .claude/skills/jgengine/api.md | 8 +- CLAUDE.md | 2 +- packages/jgengine/src/skills.ts | 4 +- scripts/api-doc-baseline.json | 35 ++++- scripts/api-orphan-baseline.json | 42 ++++-- scripts/check-skill-sync.ts | 26 ++-- scripts/gen-skill-api-safe.ts | 14 +- scripts/skillRouting.ts | 3 +- scripts/stage-skills.ts | 16 +-- 36 files changed, 508 insertions(+), 482 deletions(-) delete mode 100644 .claude/skills/jgengine-procedural/SKILL.md delete mode 100644 .claude/skills/jgengine-procedural/api.md delete mode 100644 .claude/skills/jgengine-procedural/capabilities.md delete mode 100644 .claude/skills/jgengine-procedural/reference.md diff --git a/.claude/skills/game/SKILL.md b/.claude/skills/game/SKILL.md index f29221bd5..1f72d5d3c 100644 --- a/.claude/skills/game/SKILL.md +++ b/.claude/skills/game/SKILL.md @@ -1,16 +1,16 @@ ---- +--- name: game -description: Alias — build a game to harvest engine gaps. Use harvest-game for a minimal probe or harvest-full-game for a full build. Invoke with a game name ("like "), a genre, or nothing to have one picked. +description: Alias — build a game to harvest engine gaps. Use harvest-game for a minimal probe or harvest-full-game for a full build. Invoke with a game name ("like "), a genre, or nothing to have one picked. disable-model-invocation: true --- -# game — alias +# game — alias This skill is a thin redirect. Pick the real one by scope: -- **`harvest-game`** — build a minimal playable slice to probe the engine surface for gaps. Use this for a quick harvest. -- **`harvest-full-game`** — build the whole game from the compact `jgengine` intake and routed API domains. Use this when you want the deep gaps only a full build reaches. +- **`harvest-game`** — build a minimal playable slice to probe the engine surface for gaps. Use this for a quick harvest. +- **`harvest-full-game`** — build the whole game from the compact `jgengine` intake and routed API domains. Use this when you want the deep gaps only a full build reaches. -Both run the same loop (research → build → track gaps → verify per `jgengine-verify` → file `[FEATURE]` issue(s) → push, PR, merge). Invoke the one that matches your scope; do not follow this file directly. +Both run the same loop (research → build → track gaps → verify per `jgengine-verify` → file `[FEATURE]` issue(s) → push, PR, subscribe — never merge; the user merges). Invoke the one that matches your scope; do not follow this file directly. diff --git a/.claude/skills/harvest-full-game/SKILL.md b/.claude/skills/harvest-full-game/SKILL.md index 93031524c..075d5d511 100644 --- a/.claude/skills/harvest-full-game/SKILL.md +++ b/.claude/skills/harvest-full-game/SKILL.md @@ -1,4 +1,4 @@ ---- +--- name: harvest-full-game description: Run the full game loop end to end at full scope - research a game, build the WHOLE game from the jgengine intake and selectively routed API domains, track engine gaps, and file them as [FEATURE] issues. disable-model-invocation: true @@ -6,23 +6,23 @@ disable-model-invocation: true # The harvest-full-game loop -Same harvest as `harvest-game` — build a game, harvest the engine gaps it exposes — but the deliverable is the **complete game**, not a minimal slice. Building the whole thing surfaces the deep gaps a slice never reaches: economy, progression, quests, multiplayer sync, content-scale generators. If you only want a quick probe, use `harvest-game` instead. +Same harvest as `harvest-game` — build a game, harvest the engine gaps it exposes — but the deliverable is the **complete game**, not a minimal slice. Building the whole thing surfaces the deep gaps a slice never reaches: economy, progression, quests, multiplayer sync, content-scale generators. If you only want a quick probe, use `harvest-game` instead. Take the invocation argument and resolve a target game: -- **A specific game named** → that's the target. Research it. -- **A link (repo or game page)** → that's the target, and the link is its spec. Read it for mechanics, every signature system, camera, controls, and content breadth — it is a research source, never a port source. Build fresh from the engine surface; translating its code line-by-line faithfully recreates its workarounds and hides exactly the gaps this loop exists to expose. Even when the user explicitly asks for a 1:1 port of an open-source game, the same rule holds: copy its behavior and data (numbers, tables, layouts, palettes, formulas, feel) and, where the license allows, its assets — never its functions, custom renderers, or workaround systems; rebuild on engine seams, and file the gap when a seam is missing. -- **A genre** → pick a well-known game in that genre whose full-scope mechanics stress engine areas no existing `Games/*` game already covers. -- **Nothing / "find one"** → web-research currently popular or recently viral games and pick one the engine can't obviously do yet. Prefer games whose defining mechanic looks missing from the engine surface. +- **A specific game named** → that's the target. Research it. +- **A link (repo or game page)** → that's the target, and the link is its spec. Read it for mechanics, every signature system, camera, controls, and content breadth — it is a research source, never a port source. Build fresh from the engine surface; translating its code line-by-line faithfully recreates its workarounds and hides exactly the gaps this loop exists to expose. Even when the user explicitly asks for a 1:1 port of an open-source game, the same rule holds: copy its behavior and data (numbers, tables, layouts, palettes, formulas, feel) and, where the license allows, its assets — never its functions, custom renderers, or workaround systems; rebuild on engine seams, and file the gap when a seam is missing. +- **A genre** → pick a well-known game in that genre whose full-scope mechanics stress engine areas no existing `Games/*` game already covers. +- **Nothing / "find one"** → web-research currently popular or recently viral games and pick one the engine can't obviously do yet. Prefer games whose defining mechanic looks missing from the engine surface. Then run the whole loop in this session: 1. **Research.** Learn the target well enough to fill `jgengine`'s short numbered intake: POV, world, core loop, interaction, combat, progression, players, UI, art direction, and an observable done scenario. Keep the game profile in working notes only. -2. **Work on your session's branch**, per the root workflow — the PR comes later, when the game is real. +2. **Work on your session's branch**, per the root workflow — the PR comes later, when the game is real. 3. **Build the whole game** in `Games/`. Fill the compact `jgengine` intake (the main skill is the foundation), read only the selected domain skills, then build straight through. Keep every system end-to-end, hit content breadth combinatorially, and dress the world. Build from documented engine surfaces, not copied game code. -4. **Track gaps the moment you hit them** in your working notes — one raw engine problem per line, engine terms only, no game context, no solutions. The bar is not "the engine couldn't do it" — friction is a gap too: anything the game had to hand-roll that should be a natural engine primitive or one-liner. Tag each line `blocker` (no engine-surface route existed), `workaround` (a route existed but the game had to hand-roll something the engine should own), or `ergonomics` (it worked but took boilerplate a primitive would erase). A gap you hit while building outranks a gap you suspect from reading; note both, but only after genuinely attempting the engine-surface route. Note: `jgengine`'s "Engine gaps" section says to fix a truly-missing primitive directly in this repo — for a harvest run, prefer logging the gap over closing it inline unless the fix is small and unblocks the phase, so the [FEATURE] list stays the record of what a full build actually exposed. -5. **Verify** per the intake's observable done scenario and the `jgengine-verify` skill: `bun run check-types`, `bun test packages Games` (including the co-located `.world.test.ts` `summarizeEnvironment` assertions for every `environment()` world), then `bun run shoot --mode ui` and `--mode play` as the final human glance — open the PNGs and actually look at them. A hung shot is never re-run in the foreground; the world test is what proves the scene resolved. -6. **Session end.** File the gaps as `[FEATURE]` issue(s) on this repo — title `[FEATURE] `, body a numbered list of the raw engine problems, each carrying its `blocker` / `workaround` / `ergonomics` tag (nothing else, no game context, no solutions). Then ship per CLAUDE.md's ship rule: push, open the PR (GitHub MCP `create_pull_request`, ready for review), `subscribe_pr_activity`, confirm the PR's checks go green — never merge; the user merges on request. +4. **Track gaps the moment you hit them** in your working notes — one raw engine problem per line, engine terms only, no game context, no solutions. The bar is not "the engine couldn't do it" — friction is a gap too: anything the game had to hand-roll that should be a natural engine primitive or one-liner. Tag each line `blocker` (no engine-surface route existed), `workaround` (a route existed but the game had to hand-roll something the engine should own), or `ergonomics` (it worked but took boilerplate a primitive would erase). A gap you hit while building outranks a gap you suspect from reading; note both, but only after genuinely attempting the engine-surface route. Note: `jgengine`'s "Engine gaps" section says to fix a truly-missing primitive directly in this repo — for a harvest run, prefer logging the gap over closing it inline unless the fix is small and unblocks the phase, so the [FEATURE] list stays the record of what a full build actually exposed. +5. **Verify** per the intake's observable done scenario and the `jgengine-verify` skill: `bun run check-types`, `bun test packages Games` (including the co-located `.world.test.ts` `summarizeEnvironment` assertions for every `environment()` world), then `bun run shoot --mode ui` and `--mode play` as the final human glance — open the PNGs and actually look at them. A hung shot is never re-run in the foreground; the world test is what proves the scene resolved. +6. **Session end.** File the gaps as `[FEATURE]` issue(s) on this repo — title `[FEATURE] `, body a numbered list of the raw engine problems, each carrying its `blocker` / `workaround` / `ergonomics` tag (nothing else, no game context, no solutions). Then ship per CLAUDE.md's ship rule: push, open the PR (GitHub MCP `create_pull_request`, ready for review), `subscribe_pr_activity`, confirm the PR's checks go green — never merge; the user merges on request. Because a full build may span sessions, hand off the numbered intake, completed work, and running gap list when needed. File issues only after the game is complete. diff --git a/.claude/skills/harvest-game/SKILL.md b/.claude/skills/harvest-game/SKILL.md index c3986d735..2f2a45a69 100644 --- a/.claude/skills/harvest-game/SKILL.md +++ b/.claude/skills/harvest-game/SKILL.md @@ -1,4 +1,4 @@ ---- +--- name: harvest-game description: Run the full game loop end to end - research a game, build a minimal version in Games/, track engine gaps as you hit them, and file them as [FEATURE] issue(s) at session end. Invoke with a game name ("like "), a genre, a link (repo or game page), or nothing to have one picked. disable-model-invocation: true @@ -8,19 +8,19 @@ disable-model-invocation: true Build a game, harvest the engine gaps it exposes. Take the invocation argument and resolve a target game: -- **A specific game named** → that's the target. Research it. -- **A link (repo or game page)** → that's the target, and the link is its spec. Read it for mechanics, systems, camera, controls, and content scope — it is a research source, never a port source. Build fresh from the engine surface; translating its code line-by-line faithfully recreates its workarounds and hides exactly the gaps this loop exists to expose. Even when the user explicitly asks for a 1:1 port of an open-source game, the same rule holds: copy its behavior and data (numbers, tables, layouts, palettes, formulas, feel) and, where the license allows, its assets — never its functions, custom renderers, or workaround systems; rebuild on engine seams, and file the gap when a seam is missing. -- **A genre** → pick a well-known game in that genre whose core mechanics stress engine areas no existing `Games/*` game already covers. -- **Nothing / "find one"** → web-research currently popular or recently viral games and pick one the engine can't obviously do yet. Prefer games whose defining mechanic looks missing from the engine surface. +- **A specific game named** → that's the target. Research it. +- **A link (repo or game page)** → that's the target, and the link is its spec. Read it for mechanics, systems, camera, controls, and content scope — it is a research source, never a port source. Build fresh from the engine surface; translating its code line-by-line faithfully recreates its workarounds and hides exactly the gaps this loop exists to expose. Even when the user explicitly asks for a 1:1 port of an open-source game, the same rule holds: copy its behavior and data (numbers, tables, layouts, palettes, formulas, feel) and, where the license allows, its assets — never its functions, custom renderers, or workaround systems; rebuild on engine seams, and file the gap when a seam is missing. +- **A genre** → pick a well-known game in that genre whose core mechanics stress engine areas no existing `Games/*` game already covers. +- **Nothing / "find one"** → web-research currently popular or recently viral games and pick one the engine can't obviously do yet. Prefer games whose defining mechanic looks missing from the engine surface. Then run the whole loop in this session: -1. **Research.** Web-search the target's core loop, mechanics, camera, controls, win conditions, and HUD until you can describe a minimal playable slice. Keep the game profile in your working notes only — the game's name and genre must never appear in the filed issue or any committed text other than the game's own directory. -2. **Work on your session's branch**, per the root workflow — the PR comes later, when the game is real. -3. **Build** a minimal playable version in `Games/` — core loop playable, not a full clone. Minimal never means graybox: the slice carries the target's *identity* — a named aesthetic with a committed palette (ground, sky, UI panel/ink/accent hex values), UI copy in the fantasy's own voice, and an initial camera framing that shows the game at its best — because presentation is where a whole class of engine gaps lives (sky/backdrop, time-of-day lighting, palette seams, camera pose) and none of them surface on a gray plane. Give the slice a timed observable acceptance up front ("run untouched at max speed for five minutes: what does a player actually see?") and judge the final screenshot against it. Wire it as the other `Games/*` do: a private workspace package `@games/` with `./src` exports and no build, plus the standalone dev harness `check-game-shape` enforces (root `index.html` + `vite.config.ts`, `src/index.css` importing `./style.css`, a `"dev": "vite"` script). Games auto-register from `Games/*` in `apps/dev`'s registry and the jgengine.com Games dropdown — no registry entry, vite alias, or `@games` dependency to add by hand. Use the `jgengine` intake and its selectively routed API domains, not by copying other games. -4. **Track gaps the moment you hit them** in your working notes — one raw engine problem per line, engine terms only, no game context, no solutions. The bar is not "the engine couldn't do it" — friction is a gap too: anything the game had to hand-roll that should be a natural engine primitive or one-liner. Tag each line `blocker` (no engine-surface route existed), `workaround` (a route existed but the game had to hand-roll something the engine should own), or `ergonomics` (it worked but took boilerplate a primitive would erase). A gap you hit while building outranks a gap you suspect from reading; note both, but only after genuinely attempting the engine-surface route. -5. **Verify** per the `jgengine-verify` ladder — cheapest gate first, browser last: `bun run check-types`, then `bun test packages Games` including the co-located `.world.test.ts` `summarizeEnvironment` assertions for every `environment()` world (this is the scene-correctness gate, not the screenshot), then `bun run shoot --mode ui` and `--mode play` as the final human glance — open the PNGs and actually look at them. A hung shot is never re-run in the foreground; the world test is what proves the scene resolved. -6. **Session end.** File the gaps as `[FEATURE]` issue(s) on this repo — title `[FEATURE] `, body a numbered list of the raw engine problems, each carrying its `blocker` / `workaround` / `ergonomics` tag (nothing else, no game context, no solutions). Then ship per CLAUDE.md's ship rule: push, open the PR (GitHub MCP `create_pull_request`, ready for review), `subscribe_pr_activity`, confirm the PR's checks go green — never merge; the user merges on request. +1. **Research.** Web-search the target's core loop, mechanics, camera, controls, win conditions, and HUD until you can describe a minimal playable slice. Keep the game profile in your working notes only — the game's name and genre must never appear in the filed issue or any committed text other than the game's own directory. +2. **Work on your session's branch**, per the root workflow — the PR comes later, when the game is real. +3. **Build** a minimal playable version in `Games/` — core loop playable, not a full clone. Minimal never means graybox: the slice carries the target's *identity* — a named aesthetic with a committed palette (ground, sky, UI panel/ink/accent hex values), UI copy in the fantasy's own voice, and an initial camera framing that shows the game at its best — because presentation is where a whole class of engine gaps lives (sky/backdrop, time-of-day lighting, palette seams, camera pose) and none of them surface on a gray plane. Give the slice a timed observable acceptance up front ("run untouched at max speed for five minutes: what does a player actually see?") and judge the final screenshot against it. Wire it as the other `Games/*` do: a private workspace package `@games/` with `./src` exports and no build, plus the standalone dev harness `check-game-shape` enforces (root `index.html` + `vite.config.ts`, `src/index.css` importing `./style.css`, a `"dev": "vite"` script). Games auto-register from `Games/*` in `apps/dev`'s registry and the jgengine.com Games dropdown — no registry entry, vite alias, or `@games` dependency to add by hand. Use the `jgengine` intake and its selectively routed API domains, not by copying other games. +4. **Track gaps the moment you hit them** in your working notes — one raw engine problem per line, engine terms only, no game context, no solutions. The bar is not "the engine couldn't do it" — friction is a gap too: anything the game had to hand-roll that should be a natural engine primitive or one-liner. Tag each line `blocker` (no engine-surface route existed), `workaround` (a route existed but the game had to hand-roll something the engine should own), or `ergonomics` (it worked but took boilerplate a primitive would erase). A gap you hit while building outranks a gap you suspect from reading; note both, but only after genuinely attempting the engine-surface route. +5. **Verify** per the `jgengine-verify` ladder — cheapest gate first, browser last: `bun run check-types`, then `bun test packages Games` including the co-located `.world.test.ts` `summarizeEnvironment` assertions for every `environment()` world (this is the scene-correctness gate, not the screenshot), then `bun run shoot --mode ui` and `--mode play` as the final human glance — open the PNGs and actually look at them. A hung shot is never re-run in the foreground; the world test is what proves the scene resolved. +6. **Session end.** File the gaps as `[FEATURE]` issue(s) on this repo — title `[FEATURE] `, body a numbered list of the raw engine problems, each carrying its `blocker` / `workaround` / `ergonomics` tag (nothing else, no game context, no solutions). Then ship per CLAUDE.md's ship rule: push, open the PR (GitHub MCP `create_pull_request`, ready for review), `subscribe_pr_activity`, confirm the PR's checks go green — never merge; the user merges on request. Finish by reporting: what was built and where, the issue link(s), and how many gaps were filed. diff --git a/.claude/skills/harvest/SKILL.md b/.claude/skills/harvest/SKILL.md index 406d06e2f..8c6176fc6 100644 --- a/.claude/skills/harvest/SKILL.md +++ b/.claude/skills/harvest/SKILL.md @@ -24,7 +24,7 @@ Then run the loop in this session: 4. **Track gaps the moment you hit them** in your working notes — one raw engine problem per line, engine terms only, no solutions inline. The bar is not "the engine couldn't do it": friction is a gap too — anything you had to hand-roll that should be a natural primitive or one-liner, any default that fought you, any doc that lied. Tag each line `blocker` (no engine-surface route existed), `workaround` (a route existed but you had to hand-roll something the engine should own), or `ergonomics` (it worked but took boilerplate a primitive would erase). A gap you hit while working outranks one you suspect from reading. 5. **Harvest the gaps — close now or file.** For each tracked gap, decide: **close it now** if the fix is small, in-layer, and doesn't balloon the task's scope or risk (the engine repo's default — fix directly); **file it** as a `[FEATURE]` issue if it's a real primitive-sized change that would derail this task or needs its own design pass. Don't let a run end with a gap neither closed nor filed. When you close a gap, it earns the same verification as the task. When you file one, title `[FEATURE] `, body a numbered list of the raw engine problems each carrying its `blocker`/`workaround`/`ergonomics` tag — engine terms only, no task narrative, no solutions. 6. **Verify — via `fan-out` workers, never inline on the frontier model.** Run the `jgengine-verify` ladder cheapest-gate-first: `bun run check-types`, then `bun run test` (`bun test packages Games`), then `bun run build`. For any scene- or HUD-shaped change, prove world content with `summarizeEnvironment` assertions in `bun test` before any screenshot; `bun run shoot --mode ui|play` is a final human glance, never the inner loop, and a hung shot is never re-run in the foreground. Both the task's change and every gap you closed pass the ladder together. -7. **Ship in one motion, stay until green.** Push, open the PR (GitHub MCP `create_pull_request`, ready for review), `subscribe_pr_activity`, squash-merge it immediately (`merge_pull_request`, or queue `enable_pr_auto_merge` (squash) if checks block it) — the local gate already proved what CI would. If a fixed `[FEATURE]` gap issue was your carrier or you closed one inline, put `Closes #N` in the PR body. Per CLAUDE.md's ship rule, verify with the 60-second worker check that the merge commit's Actions run on `main` is green (no `send_later` check-ins); fix forward on red. Echo 🚀 once merged+green (chat only, never on GitHub). +7. **Ship in one motion, never merge.** Push, open the PR (GitHub MCP `create_pull_request`, ready for review), `subscribe_pr_activity`, report the link, end the turn — the user owns merging (CLAUDE.md ship rule). Never `merge_pull_request` or `enable_pr_auto_merge` unless the user asked this session. If a fixed `[FEATURE]` gap issue was your carrier or you closed one inline, put `Closes #N` in the PR body. Silence is green; a CI failure event → fix on the same branch, push, end turn. Echo 🚀 with the PR link (chat only, never on GitHub). Because the task's scope varies, so does the run: a one-line doc fix that closes a gap is a whole harvest; a refactor that exposes six primitives is a whole harvest. Don't inflate a small task into a full build, and don't ship a slice when the task asked for the whole thing — match the carrier. diff --git a/.claude/skills/jgengine-assets/SKILL.md b/.claude/skills/jgengine-assets/SKILL.md index 763a898f4..e7c9a2f51 100644 --- a/.claude/skills/jgengine-assets/SKILL.md +++ b/.claude/skills/jgengine-assets/SKILL.md @@ -22,7 +22,7 @@ Squares as enemies, colored boxes as buildings, and a flat grid floor read as *b `--kind model|pack|material|component|icon|sprite|spritePack` disambiguates a broad query, `--json` emits the ranked matches, and `findAssets(query)` from `@jgengine/assets` is the same search in code. The HUD component + curated icon catalogs are the shadcn registry at `jgengine.com/r` — 70+ presentational and engine-bound widgets (`vital-bar`, `boss-bar`, `resource-orb`, `ability-action-bar`, `inventory-slot-grid`, `dialogue-panel`, …). Search before you build: the "mana pool component" almost certainly already exists. -> **Never Kenney.** Kenney.nl assets (3D kits, 2D icon/UI packs, audio) are barred from JGengine — the author declined use. Do not add, pull, re-index, alias, or reference any `kenney-*` source; reach for the CC0 sources below instead. Quaternius is the go-to broad low-poly library. +> **Never Kenney** (repo-wide rule, see CLAUDE.md). Kenney.nl assets are barred; reach for the CC0 sources below instead — Quaternius is the go-to broad low-poly library. **Sources** (CC0 — public domain, commercial use, no attribution — unless noted): diff --git a/.claude/skills/jgengine-assets/api.md b/.claude/skills/jgengine-assets/api.md index 554036611..fae992b11 100644 --- a/.claude/skills/jgengine-assets/api.md +++ b/.claude/skills/jgengine-assets/api.md @@ -23,7 +23,6 @@ - `MaterialMapRole` (type): type MaterialMapRole = keyof typeof MATERIAL_MAP_FILES — One PBR map's role within a material: color, normal, roughness, ao, or displacement. - `MaterialMaps` (interface): interface MaterialMaps — URLs of one material's PBR maps; `ao`/`displacement` files may be absent from a rare pack. - `MaterialRef` (interface): interface MaterialRef — A resolved material: identity, attribution, and its normalized map URLs. -- `ModelDims` (interface): interface ModelDims — Measured horizontal footprint, footprint center, and lowest Y of a model in model space. - `ModelSnippetOptions` (interface): interface ModelSnippetOptions — ⚠ undocumented - `PinnedDownload` (interface): interface PinnedDownload — ⚠ undocumented - `RankedMatch` (interface): interface RankedMatch — ⚠ undocumented @@ -40,7 +39,7 @@ - `buildSpriteCatalog` (function): function buildSpriteCatalog(options: BuildSpriteCatalogOptions = {}): AssetCatalog — Resolves individual pulled sprite/icon ids (e.g. `gameicons-icons/sword`) to `{ url }`. - `componentInstallUrl` (function): function componentInstallUrl(name: string): string — The `shadcn add` URL for a HUD component, e.g. `https://jgengine.com/r/vital-bar.json`. - `componentWiringSnippet` (function): function componentWiringSnippet(component: RegistryComponent): string — Copy-paste wiring for a HUD component: the `shadcn add` command plus import + usage. -- `createStarterCatalog` (function): function createStarterCatalog(options: BuildCatalogOptions = {}): AssetCatalog — ⚠ undocumented +- `createStarterCatalog` (function): function createStarterCatalog(options: BuildCatalogOptions = {}): AssetCatalog — ⚠ undocumented - `entryForSpriteFile` (function): function entryForSpriteFile(source: AssetSource, file: string): IndexEntry — Builds one sprite/icon `IndexEntry` — same shape as a model entry, minus `dims`. - `entryUrl` (function): function entryUrl(basePath: string, entry: IndexEntry): string — ⚠ undocumented - `extractMaterialMaps` (function): function extractMaterialMaps(archive: Uint8Array): ExtractedMaterialMap[] — Pulls the recognized PBR maps out of a material archive (ambientCG's flat `__.jpg` layout) and normalizes their names so resolved URLs never depend on the provider's naming or the pulled resolution. @@ -89,7 +88,7 @@ ## @jgengine/assets/catalogs/starter -- `createStarterCatalog` (function): function createStarterCatalog(options: BuildCatalogOptions = {}): AssetCatalog — ⚠ undocumented +- `createStarterCatalog` (function): function createStarterCatalog(options: BuildCatalogOptions = {}): AssetCatalog — ⚠ undocumented ## @jgengine/assets/cli/pull @@ -99,7 +98,6 @@ ## @jgengine/assets/dims -- `ModelDims` (interface): interface ModelDims — Measured horizontal footprint, footprint center, and lowest Y of a model in model space. - `readGlbDims` (function): function readGlbDims(bytes: Uint8Array): ModelDims | null — ⚠ undocumented ## @jgengine/assets/download @@ -111,6 +109,10 @@ - `ExtractedSpriteFile` (interface): interface ExtractedSpriteFile — One SVG/PNG file pulled out of a sprite/icon-pack archive by `extractSpriteFiles`. - `ExtractedTexture` (interface): interface ExtractedTexture — ⚠ undocumented - `FetchLike` (type): type FetchLike = typeof fetch — ⚠ undocumented +- `MAX_ARCHIVE_COMPRESSION_RATIO` (const): const MAX_ARCHIVE_COMPRESSION_RATIO: 100 — Max allowed originalSize/size ratio for a single archive entry — past this it's treated as a zip bomb. +- `MAX_ARCHIVE_DOWNLOAD_BYTES` (const): const MAX_ARCHIVE_DOWNLOAD_BYTES: number — Max size of a downloaded (still-compressed) archive, in bytes. Provider zips run tens of MB; this leaves headroom without buffering an unbounded response. +- `MAX_ARCHIVE_ENTRY_COUNT` (const): const MAX_ARCHIVE_ENTRY_COUNT: 20000 — Max number of entries this module will extract out of one archive. +- `MAX_ARCHIVE_UNCOMPRESSED_BYTES` (const): const MAX_ARCHIVE_UNCOMPRESSED_BYTES: number — Max total uncompressed size this module will inflate out of one archive, in bytes. ## @jgengine/assets/find @@ -147,7 +149,6 @@ - `AssetSource` (interface): interface AssetSource — ⚠ undocumented - `AssetSourceKind` (type): type AssetSourceKind = "model" | "material" | "sprite" — What a source's archive contains: GLB models (default), one PBR material's texture maps, or a pack of individual 2D sprite/icon files (SVG/PNG). - `IndexEntry` (interface): interface IndexEntry — ⚠ undocumented -- `ModelDims` (interface): interface ModelDims — Measured horizontal footprint, footprint center, and lowest Y of a model in model space. - `PinnedDownload` (interface): interface PinnedDownload — ⚠ undocumented - `ScrapeDownload` (interface): interface ScrapeDownload — ⚠ undocumented - `SingleAsset` (interface): interface SingleAsset — ⚠ undocumented diff --git a/.claude/skills/jgengine-combat/SKILL.md b/.claude/skills/jgengine-combat/SKILL.md index 4a77f7f3d..b72bde31a 100644 --- a/.claude/skills/jgengine-combat/SKILL.md +++ b/.claude/skills/jgengine-combat/SKILL.md @@ -1,4 +1,4 @@ ---- +--- name: jgengine-combat description: Combat API: effects, projectiles, damage, abilities, loot, weapons. --- diff --git a/.claude/skills/jgengine-combat/api.md b/.claude/skills/jgengine-combat/api.md index 8ecb24383..6639ab816 100644 --- a/.claude/skills/jgengine-combat/api.md +++ b/.claude/skills/jgengine-combat/api.md @@ -93,6 +93,10 @@ - `deathReasonFromEffect` (function): function deathReasonFromEffect(ctx: EffectDeathContext): DeathReason — ⚠ undocumented - `normalizeOnDeath` (function): function normalizeOnDeath(spec: OnDeathSpec | null | undefined): NormalizedOnDeath — ⚠ undocumented +## @jgengine/core/combat/deathReason + +- `DeathReason` (type): type DeathReason = | { kind: "player_kill"; killerUserId: string; via?: { item?: string } } | { kind: "environment"; source: string } | { kind: "self"; source: string } — Why an entity died — who or what gets credit, for drop/command rules and the `entity.died` event. + ## @jgengine/core/combat/defensiveWindow - `DefenseKind` (type): type DefenseKind = "parry" | "block" | "dodge" — ⚠ undocumented diff --git a/.claude/skills/jgengine-combat/reference.md b/.claude/skills/jgengine-combat/reference.md index 8487c5192..0e7d68f5a 100644 --- a/.claude/skills/jgengine-combat/reference.md +++ b/.claude/skills/jgengine-combat/reference.md @@ -1,71 +1,71 @@ -# jgengine domain API — Combat +# jgengine domain API — Combat -Reference module for the [`jgengine-combat` API](SKILL.md) skill. Load this when you need the combat surface — effects, projectiles, death, feel, and abilities. +Reference module for the [`jgengine-combat` API](SKILL.md) skill. Load this when you need the combat surface — effects, projectiles, death, feel, and abilities. ## Effects and projectiles Effect ids are **game-defined strings**. Magnitudes **drain** stats: positive subtracts down `receive..order` (spilling to the next stat in the order), negative restores. Heals pass a negative amount (`via: { amount: -flashHeal }`, typically read from a `weapon.heal` stat). ```ts -ctx.scene.entity.canReceive(instanceId, effect, magnitude?) // null | reason — reads catalog receive +ctx.scene.entity.canReceive(instanceId, effect, magnitude?) // null | reason — reads catalog receive ctx.scene.entity.preview({ from, to, effect, via }) // magnitude, no state change ctx.scene.entity.effect({ from, to, effect, via }) // single target ctx.scene.entity.effect({ from, effect, via, at, radius, falloff?, los? }) // AoE at a point ``` -AoE: `inRadius(at, radius)` → LoS filter (default on) → `canReceive` per target → absorption; `falloff: "linear" | "none"`. `via` = `{ item }` (magnitude from weapon stats) or `{ amount }`. `canReceive`'s `pools-depleted` reason checks headroom in the effect's direction: a positive (draining) magnitude needs a stat above its min, a negative (restorative) magnitude needs a stat below its max — so a heal can still raise a stat sitting at its minimum. Omitting `magnitude` assumes the draining direction. +AoE: `inRadius(at, radius)` → LoS filter (default on) → `canReceive` per target → absorption; `falloff: "linear" | "none"`. `via` = `{ item }` (magnitude from weapon stats) or `{ amount }`. `canReceive`'s `pools-depleted` reason checks headroom in the effect's direction: a positive (draining) magnitude needs a stat above its min, a negative (restorative) magnitude needs a stat below its max — so a heal can still raise a stat sitting at its minimum. Omitting `magnitude` assumes the draining direction. -`canReceive`'s optional signed `magnitude` makes the check direction-aware: positive (or omitted) keeps the standard `"pools-depleted"` check; negative checks the opposite direction and returns `"pools-full"` only when every stat in the receive order is already at max — so a heal (`via: { amount: -n }`) correctly reaches a fully-depleted target and is rejected only when there's nothing left to restore. +`canReceive`'s optional signed `magnitude` makes the check direction-aware: positive (or omitted) keeps the standard `"pools-depleted"` check; negative checks the opposite direction and returns `"pools-full"` only when every stat in the receive order is already at max — so a heal (`via: { amount: -n }`) correctly reaches a fully-depleted target and is rejected only when there's nothing left to restore. -**Projectiles** (aim-based — no target ids): +**Projectiles** (aim-based — no target ids): ```ts willHitProjectile({ from, via, aim, effect }) // prediction only, for crosshair UI -fireProjectile({ from, via, aim, effect }) // → shotId (pending) -settleProjectile(shotId) // authoritative → { at, hits } | rejection +fireProjectile({ from, via, aim, effect }) // → shotId (pending) +settleProjectile(shotId) // authoritative → { at, hits } | rejection ``` -`Aim = { origin, direction } | { yaw, pitch, spread? }`. A free `{ yaw, pitch }` aim defaults to `converge`: the bullet leaves the gun `muzzle` (so a tracer visibly comes from the barrel, not the camera) yet bends toward whatever the eye ray's crosshair covers, so the reticle stays truth. An explicit `{ origin, direction }` aim (enemy AI, turrets) passes through unchanged. Shot origin otherwise resolves via `ShotOriginPolicy` (`eye`: shooter position raised to eye height — 90% of the shooter's own hitbox top when colliders are known, ~1.6m otherwise — so shots trace the sightline and the crosshair is truth; `muzzle`/`camera`/`world` for custom rigs, `legacy` = raw entity position). Default entity hitbox is a body-covering AABB (feet to ~1.8m). Objects with a `visual.scale` get a matching grounded physical body automatically; explicit catalog `colliders`/`halfExtents` override. Hitscan settles into per-hit effects; ballistic shots (`weapon.projectile` with `fuseTime`/`settleOn`) settle to a landing point — the handler then calls `effect({ at: settle.at, radius })`. Settling twice rejects. Prediction is never authority. +`Aim = { origin, direction } | { yaw, pitch, spread? }`. A free `{ yaw, pitch }` aim defaults to `converge`: the bullet leaves the gun `muzzle` (so a tracer visibly comes from the barrel, not the camera) yet bends toward whatever the eye ray's crosshair covers, so the reticle stays truth. An explicit `{ origin, direction }` aim (enemy AI, turrets) passes through unchanged. Shot origin otherwise resolves via `ShotOriginPolicy` (`eye`: shooter position raised to eye height — 90% of the shooter's own hitbox top when colliders are known, ~1.6m otherwise — so shots trace the sightline and the crosshair is truth; `muzzle`/`camera`/`world` for custom rigs, `legacy` = raw entity position). Default entity hitbox is a body-covering AABB (feet to ~1.8m). Objects with a `visual.scale` get a matching grounded physical body automatically; explicit catalog `colliders`/`halfExtents` override. Hitscan settles into per-hit effects; ballistic shots (`weapon.projectile` with `fuseTime`/`settleOn`) settle to a landing point — the handler then calls `effect({ at: settle.at, radius })`. Settling twice rejects. Prediction is never authority. -Raycasts are **object-aware**: the default raycast checks placed scene objects as well as entities, discriminated by `RaycastHit.kind` (`"entity" | "object"` — an `ObjectRaycastHit` also carries `catalogId`). A crate or wall between shooter and target blocks or absorbs the shot instead of every projectile passing through scenery; supply `ProjectileSystemDeps.objects` (`{ list(), halfExtents?(catalogId) }`, matching `ObjectStore.list()` structurally) to opt in, with a `[0.5, 0.5, 0.5]` half-extent default per object. +Raycasts are **object-aware**: the default raycast checks placed scene objects as well as entities, discriminated by `RaycastHit.kind` (`"entity" | "object"` — an `ObjectRaycastHit` also carries `catalogId`). A crate or wall between shooter and target blocks or absorbs the shot instead of every projectile passing through scenery; supply `ProjectileSystemDeps.objects` (`{ list(), halfExtents?(catalogId) }`, matching `ObjectStore.list()` structurally) to opt in, with a `[0.5, 0.5, 0.5]` half-extent default per object. ## Death Resolved **once** by the engine when the last stat in the receive order hits min. No HP polling in `onTick`, ever. -- `entity.died` is emitted (before despawn — handlers can still read the victim's stats), then reason-matching `onDeath` entries run. +- `entity.died` is emitted (before despawn — handlers can still read the victim's stats), then reason-matching `onDeath` entries run. - `DeathReason = { kind: "player_kill", killerUserId, via? } | { kind: "environment", source } | { kind: "self", source }`. Kills by the local player attribute automatically. - `onDeath.drops` tables are rolled and **granted to the killer** on player kills (emits `loot.granted`) when `onDeath.dropMode` is `"grant"` (default); `onDeath.command` runs through `ctx.game.commands`. -- `onDeath.dropMode: "world"` routes item drops through a scatter impulse into ground `worldItem`s instead of straight to inventory (currency drops still grant directly) — tune the impulse with `onDeath.scatter: { radius, minRadius?, height? }` (defaults from `game/worldItem`'s `DEFAULT_SCATTER`). -- Respawning under the same instance id revives it (it can die again). Same-id respawn must not happen synchronously inside the `entity.died` handler — defer a tick. +- `onDeath.dropMode: "world"` routes item drops through a scatter impulse into ground `worldItem`s instead of straight to inventory (currency drops still grant directly) — tune the impulse with `onDeath.scatter: { radius, minRadius?, height? }` (defaults from `game/worldItem`'s `DEFAULT_SCATTER`). +- Respawning under the same instance id revives it (it can die again). Same-id respawn must not happen synchronously inside the `entity.died` handler — defer a tick. - `quest.bind("entity.died")` credits kill objectives from the same event; leaderboards and kill feeds hang off it too. ## Combat feel (melee, defense, telegraphs) -Layered on top of effects/projectiles/death — none of it replaces them, it adds **feel**. All models are renderer-free pure `@jgengine/core` factories a game composes per entity (like the `ctx`-vs-factory split above); the shell renders the telegraphs, styled damage numbers, hitstop shake. +Layered on top of effects/projectiles/death — none of it replaces them, it adds **feel**. All models are renderer-free pure `@jgengine/core` factories a game composes per entity (like the `ctx`-vs-factory split above); the shell renders the telegraphs, styled damage numbers, hitstop shake. -- **Animation state machine** (`combat/animationState`) is the root the rest hangs on. A `AnimationClip` is catalog data — `{ frames, fps, ranges }` where each `FrameRange` tags a window `windup | active | recovery | cancel` (cancel may overlap recovery). `createAnimationState({ clips })` gives a per-entity SM: `play(clipId)`, `tick(dt)` → `{ entered, exited, completed }`, and queries combat/defense subscribe to — `inPhase("active")`, `isActive()`, `canCancel()`, `activeWindowMs()`. Frame ranges are the "commit frame" contract for delayed/feinted attacks. -- **Attack tags** (`combat/attackTags`) — `attackMeta(["unblockable" | "thrust" | "sweep" | "grab" | …], { effect, power })`. Defense logic reads them: `isBlockable`, `isParryable`, `isDodgeable`, `counters(meta, "mikiri")`. A grab beats all defenses; an unblockable is parry/dodge-only. -- **Defensive window** (`combat/defensiveWindow`) — a parry/block/dodge with `{ startupMs, activeMs, recoveryMs, iframes }`. `resolveDefense({ config, elapsedMs, attack })` is the pure overlap of the defender's window against the moment the attacker's `active` frames land → `parry | block | iframe | hit`. `createDefensiveWindow(config)` tracks the open time (`open(now)` / `evaluate(now, attack)` / `isInvulnerable(now)`). -- **Combo strings** (`combat/comboString`) — `ComboStep`s with `cancelInto` + `cancelPhases` + optional `stance`, over the anim SM. `advanceCombo(...)` (pure) accepts the next attack only inside the current step's cancel window and matching stance; `createComboRunner(combo, anim)` drives the SM. -- **Meters share one accumulator** (`stats/accumulatorMeter`) — `createAccumulatorMeter({ max, mode: "hold" | "reset", decayPerSecond, decayDelayMs, tiers })`: fill via `add(n)` → `MeterAddResult { fired, overflow, tier, tierChanged }`, `tick(dt)` decays after an idle grace, reading its current tier off the pure `tierAt(value, tiers)` lookup. `combat/breakMeters` builds two on it: `createStaggerMeter` (mode `hold` — fills from hits, `broke()` stays true until `recover()` after a riposte/deathblow) and `createBuildupMeter` (mode `reset` — `add(n)` returns a `BuildupProc { status, durationMs }` at threshold for bleed/frost/rot, then decays). The same base backs G6's ult/streak meters. -- **Delayed-regen shield** (`combat/regenShield`) — `createRegenShield(config)` is a shield pool that pauses regen for `regenDelayMs` after every hit, then refills at `regenPerSecond`; `damage`/`tick` replace snapshot-comparing a stat to detect "took damage." -- **DoT field** (`combat/dotField`) — `createDotField()` schedules recurring damage-over-time instances (bleed/poison/burn) keyed by id; `tick` drains due damage per interval. Pairs with `combat/breakMeters`' `BuildupProc` — a fired buildup applies a DoT instead of one-shot damage. +- **Animation state machine** (`combat/animationState`) is the root the rest hangs on. A `AnimationClip` is catalog data — `{ frames, fps, ranges }` where each `FrameRange` tags a window `windup | active | recovery | cancel` (cancel may overlap recovery). `createAnimationState({ clips })` gives a per-entity SM: `play(clipId)`, `tick(dt)` → `{ entered, exited, completed }`, and queries combat/defense subscribe to — `inPhase("active")`, `isActive()`, `canCancel()`, `activeWindowMs()`. Frame ranges are the "commit frame" contract for delayed/feinted attacks. +- **Attack tags** (`combat/attackTags`) — `attackMeta(["unblockable" | "thrust" | "sweep" | "grab" | …], { effect, power })`. Defense logic reads them: `isBlockable`, `isParryable`, `isDodgeable`, `counters(meta, "mikiri")`. A grab beats all defenses; an unblockable is parry/dodge-only. +- **Defensive window** (`combat/defensiveWindow`) — a parry/block/dodge with `{ startupMs, activeMs, recoveryMs, iframes }`. `resolveDefense({ config, elapsedMs, attack })` is the pure overlap of the defender's window against the moment the attacker's `active` frames land → `parry | block | iframe | hit`. `createDefensiveWindow(config)` tracks the open time (`open(now)` / `evaluate(now, attack)` / `isInvulnerable(now)`). +- **Combo strings** (`combat/comboString`) — `ComboStep`s with `cancelInto` + `cancelPhases` + optional `stance`, over the anim SM. `advanceCombo(...)` (pure) accepts the next attack only inside the current step's cancel window and matching stance; `createComboRunner(combo, anim)` drives the SM. +- **Meters share one accumulator** (`stats/accumulatorMeter`) — `createAccumulatorMeter({ max, mode: "hold" | "reset", decayPerSecond, decayDelayMs, tiers })`: fill via `add(n)` → `MeterAddResult { fired, overflow, tier, tierChanged }`, `tick(dt)` decays after an idle grace, reading its current tier off the pure `tierAt(value, tiers)` lookup. `combat/breakMeters` builds two on it: `createStaggerMeter` (mode `hold` — fills from hits, `broke()` stays true until `recover()` after a riposte/deathblow) and `createBuildupMeter` (mode `reset` — `add(n)` returns a `BuildupProc { status, durationMs }` at threshold for bleed/frost/rot, then decays). The same base backs G6's ult/streak meters. +- **Delayed-regen shield** (`combat/regenShield`) — `createRegenShield(config)` is a shield pool that pauses regen for `regenDelayMs` after every hit, then refills at `regenPerSecond`; `damage`/`tick` replace snapshot-comparing a stat to detect "took damage." +- **DoT field** (`combat/dotField`) — `createDotField()` schedules recurring damage-over-time instances (bleed/poison/burn) keyed by id; `tick` drains due damage per interval. Pairs with `combat/breakMeters`' `BuildupProc` — a fired buildup applies a DoT instead of one-shot damage. - **Magazine** (`combat/magazine`) — `createMagazine({ capacity, reloadMs, loaded?, reserve? })` is the per-weapon mag-size/reload/reserve-pool primitive (#536.2): `fire(rounds?)` spends loaded ammo, `startReload()`/`tick(dt)` run the timed refill, `canFire`/`canReload`/`isEmpty`/`isFull`/`reloadFraction` drive the HUD. `reserve` is a plain number for a self-contained pool, a `MagazineReserve` (`{ current, spend, gain? }`) to bridge reload draws into an externally-owned pool (a shared ammo-type stat, another `ResourcePool`), or omitted for an infinite reserve. -- **Dash / dodge** (`movement/dash`) — `createDashState({ distance, durationMs, iframes, staminaCost, staminaMax, staminaRegenPerSecond, cooldownMs })`: `tryDash(dir, now)` → burst or `{ reason: "no-stamina" | "cooldown" | "dashing" }`, `isInvulnerable(now)` for the i-frame window, `offset(now)` for the burst displacement, `tick(dt, now)` regens stamina. +- **Dash / dodge** (`movement/dash`) — `createDashState({ distance, durationMs, iframes, staminaCost, staminaMax, staminaRegenPerSecond, cooldownMs })`: `tryDash(dir, now)` → burst or `{ reason: "no-stamina" | "cooldown" | "dashing" }`, `isInvulnerable(now)` for the i-frame window, `offset(now)` for the burst displacement, `tick(dt, now)` regens stamina. - **Hit reaction** (`combat/hitReaction`) — `resolveHitReaction(config, { attackerPos, targetPos, power })` (pure) → `{ hitstopMs, impulse, shake, trauma, timescale }`. `config` is a raw `HitReactionConfig` **or** a named `impactPresets` event — `resolveHitReaction("explosion", input)` gives calibrated hitstop + trauma with zero hand-tuning (`pickup` 0.15 / `jumpLand` 0.2 / `enemyKilled` 0.3 @ 40ms hitstop / `playerHit` 0.4 @ 70ms / `explosion` 0.7 @ 90ms, heavy hits at `timescale: 0.05`; trauma clamps to 1.0). Wired on `ctx`: `ctx.scene.entity.hitReaction({ from, to, config, power? })` knocks the target back and emits `combat.hitReaction` with `trauma` set when the config resolved from `trauma` — the shell's `CombatCameraShake` feeds it into the trauma² camera-shake channel (`@jgengine/shell/camera`'s `traumaShake`; see `jgengine-ui`), or reads `shake` for the amplitude/decay channel when `config.shake` is set instead. -- **Render cues** (`combat/renderCues`, #542) - the pure math behind a custom `renderEntity`/viewmodel rig's motion feel: `EntityRenderCues { speed, bobPhase, firing, reloading, recoil, hit, dead }`, `advanceMotionCues(cues, speed, dt, tuning?)` advances `bobPhase`/decays `recoil` from a live `groundSpeed(entity)` sample, `applyRenderAnimationEvent(cues, event)` folds in `"fire"` / `"reload"` / `"reloadEnd"` from `ctx.game.playEntityAnimation`, `applyRenderHitEvent`/`applyRenderDeathEvent` fold in `combat.hitReaction`/`entity.died`. `RenderCueTuning` overrides `cyclesPerUnit`/`recoilDecayPerSecond`/`firePulseSeconds`/`hitPulseSeconds`. A game never calls these directly — `@jgengine/shell/render`'s `useEntityRenderCues(instanceId)` (see `jgengine-ui`) wires all four into one ref-returning hook for `useFrame` consumers. -- **Telegraph** (`combat/telegraph`) — a `TelegraphShape` (`circle | ring | cone | line`) + `windupMs`. `ctx.scene.entity.telegraph({ from, shape, at, dir?, windupMs, kind?, effect? })` emits `combat.telegraph` for the shell to draw a ground decal that fills over the windup, and — if `effect` is bound — applies that effect to everyone inside the shape (`pointInTelegraph`) at activation. Returns a cancel handle. **Turn-scoped variant**, for tactics/turn-based enemy intents instead of a real-time windup: set `TelegraphConfig.turns` (a count of turns instead of `windupMs`), then read `telegraphTurnProgress(config, startedTurn, currentTurn)` (0..1 fill fraction), `telegraphFiredAtTurn(config, startedTurn, currentTurn)` (has it resolved), and `telegraphTurnsRemaining(config, startedTurn, currentTurn)` — the same "enemy will hit this tile" decal, advanced by `turnLoop.round()`/`active()` instead of a clock. -- **Damage-number typing** — `ctx.scene.entity.floatText({ …, crit?, element?, hitType?, scale? })` (and the `entity.floatText` event) carry hit metadata; the shell's `resolveFloatTextStyle` (`@jgengine/shell/world/floatTextStyle`) maps crit/element to color + scale + glow. +- **Render cues** (`combat/renderCues`, #542) - the pure math behind a custom `renderEntity`/viewmodel rig's motion feel: `EntityRenderCues { speed, bobPhase, firing, reloading, recoil, hit, dead }`, `advanceMotionCues(cues, speed, dt, tuning?)` advances `bobPhase`/decays `recoil` from a live `groundSpeed(entity)` sample, `applyRenderAnimationEvent(cues, event)` folds in `"fire"` / `"reload"` / `"reloadEnd"` from `ctx.game.playEntityAnimation`, `applyRenderHitEvent`/`applyRenderDeathEvent` fold in `combat.hitReaction`/`entity.died`. `RenderCueTuning` overrides `cyclesPerUnit`/`recoilDecayPerSecond`/`firePulseSeconds`/`hitPulseSeconds`. A game never calls these directly — `@jgengine/shell/render`'s `useEntityRenderCues(instanceId)` (see `jgengine-ui`) wires all four into one ref-returning hook for `useFrame` consumers. +- **Telegraph** (`combat/telegraph`) — a `TelegraphShape` (`circle | ring | cone | line`) + `windupMs`. `ctx.scene.entity.telegraph({ from, shape, at, dir?, windupMs, kind?, effect? })` emits `combat.telegraph` for the shell to draw a ground decal that fills over the windup, and — if `effect` is bound — applies that effect to everyone inside the shape (`pointInTelegraph`) at activation. Returns a cancel handle. **Turn-scoped variant**, for tactics/turn-based enemy intents instead of a real-time windup: set `TelegraphConfig.turns` (a count of turns instead of `windupMs`), then read `telegraphTurnProgress(config, startedTurn, currentTurn)` (0..1 fill fraction), `telegraphFiredAtTurn(config, startedTurn, currentTurn)` (has it resolved), and `telegraphTurnsRemaining(config, startedTurn, currentTurn)` — the same "enemy will hit this tile" decal, advanced by `turnLoop.round()`/`active()` instead of a clock. +- **Damage-number typing** — `ctx.scene.entity.floatText({ …, crit?, element?, hitType?, scale? })` (and the `entity.floatText` event) carry hit metadata; the shell's `resolveFloatTextStyle` (`@jgengine/shell/world/floatTextStyle`) maps crit/element to color + scale + glow. ## Abilities, resources, auto-target, resistance, run drafts -Genre systems layered over the same effects/projectiles/targeting/loot primitives — all renderer-free pure `@jgengine/core` factories a game holds per player and ticks on **game-time** `dt` (so pause/fast-forward carry through). The ability kit is deliberately **separate from inventory items**: an item is a stackable id, an ability slot is cooldown/charge/resource state the HUD's four slot-states bind to. +Genre systems layered over the same effects/projectiles/targeting/loot primitives — all renderer-free pure `@jgengine/core` factories a game holds per player and ticks on **game-time** `dt` (so pause/fast-forward carry through). The ability kit is deliberately **separate from inventory items**: an item is a stackable id, an ability slot is cooldown/charge/resource state the HUD's four slot-states bind to. -- **Ability kit** (`combat/abilityKit`) — `createAbilityKit([{ id, cooldownMs, chargesMax?, resourceCost?, castType?, flashMs? }])`. `state(id, resourceAvailable?)` → `AbilitySlotSnapshot { state: "ready" | "cooldown" | "no-resource" | "just-cast", charges, chargesMax, cooldownRemainingMs, cooldownFraction, justCast, ready }`; `cast(id, resourceAvailable?)` consumes a charge, starts the recharge, and flashes just-cast; `canCast` / `tick(dt)` / `reset` / `retuneSlot(id, { cooldownMs?, resourceCost? })` (rebalance a slot at runtime — patches the config in place without touching charges or an in-flight cooldown timer, returns `false` for an unknown slot). The kit is resource-**agnostic** — it reports `no-resource` by comparing `resourceCost` to a supplied `resourceAvailable` (a mana stat, or an ult meter), and never spends the resource itself; the game's handler spends it and calls `cast`. Charges recharge one at a time; the four states drive the hotbar slot art. Cooldowns tick on `dt`, so hang `kit.tick(dt)` in `onTick`. -- **Event-fed meters** (`stats/eventMeter`, built on `stats/accumulatorMeter`) — `createEventMeter({ max, mode, gains, resets?, tiers?, decayPerSecond? })`. `feed(tag, scale?)` maps a tagged combat event to a gain (or a reset when `tag ∈ resets`) → `EventMeterFeedResult { fired, ready, tier, tierChanged, reset }`. Mode `"hold"` is the **ult/adrenaline** economy — charges off `{ damageDealt, damageTaken, kill }` events, `ready()` at full, `consume()` spends it (Overwatch/Marvel Rivals). Mode `"reset"` is the **streak/combo** meter — builds on `kill`, resets on a break tag like `damageTaken`, climbs ascending `tiers` (Returnal adrenaline, DMC style rank). One primitive, two catalog configs. -- **Auto-target policy** (`scene/autoTarget`) — `selectAutoTarget(policy, fromId, deps)` / `createAutoTargeter(policy, deps)` evaluated each tick with zero input: `"nearest" | "farthest" | "random" | "strongest" | "weakest" | "first" | "last"`. `deps` supplies `candidates`/`distance` and optional `strength` (health/threat) + `progress` (path progress for first/last-on-path). Bullet-heaven auto-fire (Vampire Survivors nearest), Bloons tower priority (first/last on path). Feed the picked id to `abilityKit.cast` + `effect`. -- **Resistance matrix** (`combat/resistance`) — a damage-category × target-property table distinct from `combat/attackTags` (those are defense tags). `resolveResistance(matrix, category, targetProperties)` → `{ verdict: "immune" | "resist" | "normal" | "vulnerable", multiplier, immune }`; `resistanceScale` returns just the multiplier (default `immune:0, resist:0.5, normal:1, vulnerable:2`, overridable). Immune on any property wins; resists stack multiplicatively. Sits over the `receive` gate — the handler multiplies its effect `amount` by the scale (Bloons lead/camo immunities, elemental RPG weaknesses). -- **Run draft** (`game/runDraft`, built on `world/scatterItems` `pickWeighted` + `stats/statModifiers`) — `createRunModifierStack(offers)` accumulates stacking picks (`add`, `count`, `atMax`, `total(stat)` aggregating adds × exponentiated multiplies, `apply(stats)` onto a `createStats` source). `createRunDraft({ offers, rng? })` adds the pause-flow: `present(n)` draws N distinct weighted offers (excluding maxed-out ones), `choose(id)` applies to the stack. Pause/resume is game-side (`ctx.time.pause()` before `present`, `play()` after `choose`). Vampire Survivors level-up picks, Hades boons, Risk of Rain stacking items. +- **Ability kit** (`combat/abilityKit`) — `createAbilityKit([{ id, cooldownMs, chargesMax?, resourceCost?, castType?, flashMs? }])`. `state(id, resourceAvailable?)` → `AbilitySlotSnapshot { state: "ready" | "cooldown" | "no-resource" | "just-cast", charges, chargesMax, cooldownRemainingMs, cooldownFraction, justCast, ready }`; `cast(id, resourceAvailable?)` consumes a charge, starts the recharge, and flashes just-cast; `canCast` / `tick(dt)` / `reset` / `retuneSlot(id, { cooldownMs?, resourceCost? })` (rebalance a slot at runtime — patches the config in place without touching charges or an in-flight cooldown timer, returns `false` for an unknown slot). The kit is resource-**agnostic** — it reports `no-resource` by comparing `resourceCost` to a supplied `resourceAvailable` (a mana stat, or an ult meter), and never spends the resource itself; the game's handler spends it and calls `cast`. Charges recharge one at a time; the four states drive the hotbar slot art. Cooldowns tick on `dt`, so hang `kit.tick(dt)` in `onTick`. +- **Event-fed meters** (`stats/eventMeter`, built on `stats/accumulatorMeter`) — `createEventMeter({ max, mode, gains, resets?, tiers?, decayPerSecond? })`. `feed(tag, scale?)` maps a tagged combat event to a gain (or a reset when `tag ∈ resets`) → `EventMeterFeedResult { fired, ready, tier, tierChanged, reset }`. Mode `"hold"` is the **ult/adrenaline** economy — charges off `{ damageDealt, damageTaken, kill }` events, `ready()` at full, `consume()` spends it (Overwatch/Marvel Rivals). Mode `"reset"` is the **streak/combo** meter — builds on `kill`, resets on a break tag like `damageTaken`, climbs ascending `tiers` (Returnal adrenaline, DMC style rank). One primitive, two catalog configs. +- **Auto-target policy** (`scene/autoTarget`) — `selectAutoTarget(policy, fromId, deps)` / `createAutoTargeter(policy, deps)` evaluated each tick with zero input: `"nearest" | "farthest" | "random" | "strongest" | "weakest" | "first" | "last"`. `deps` supplies `candidates`/`distance` and optional `strength` (health/threat) + `progress` (path progress for first/last-on-path). Bullet-heaven auto-fire (Vampire Survivors nearest), Bloons tower priority (first/last on path). Feed the picked id to `abilityKit.cast` + `effect`. +- **Resistance matrix** (`combat/resistance`) — a damage-category × target-property table distinct from `combat/attackTags` (those are defense tags). `resolveResistance(matrix, category, targetProperties)` → `{ verdict: "immune" | "resist" | "normal" | "vulnerable", multiplier, immune }`; `resistanceScale` returns just the multiplier (default `immune:0, resist:0.5, normal:1, vulnerable:2`, overridable). Immune on any property wins; resists stack multiplicatively. Sits over the `receive` gate — the handler multiplies its effect `amount` by the scale (Bloons lead/camo immunities, elemental RPG weaknesses). +- **Run draft** (`game/runDraft`, built on `world/scatterItems` `pickWeighted` + `stats/statModifiers`) — `createRunModifierStack(offers)` accumulates stacking picks (`add`, `count`, `atMax`, `total(stat)` aggregating adds × exponentiated multiplies, `apply(stats)` onto a `createStats` source). `createRunDraft({ offers, rng? })` adds the pause-flow: `present(n)` draws N distinct weighted offers (excluding maxed-out ones), `choose(id)` applies to the stack. Pause/resume is game-side (`ctx.time.pause()` before `present`, `play()` after `choose`). Vampire Survivors level-up picks, Hades boons, Risk of Rain stacking items. diff --git a/.claude/skills/jgengine-editor/api.md b/.claude/skills/jgengine-editor/api.md index ea84fe15a..c33d8da31 100644 --- a/.claude/skills/jgengine-editor/api.md +++ b/.claude/skills/jgengine-editor/api.md @@ -58,7 +58,7 @@ - `DEFAULT_PAINT_SETTINGS` (const): const DEFAULT_PAINT_SETTINGS: PaintSettings — The terrain tool's default paint controls. - `DEFAULT_SCULPT_SETTINGS` (const): const DEFAULT_SCULPT_SETTINGS: SculptSettings — The terrain tool's default brush controls. - `EDITOR_MCP_TOOLS` (const): const EDITOR_MCP_TOOLS: readonly EditorMcpTool[] — Full set of MCP tools an agent can call to drive the live scene editor. -- `EditorApp` (function): function EditorApp({ gameId, playable, layers, save }: EditorAppProps): React.JSX.Element — Top-level scene editor: author spawns/zones/paths/notes visually over edit, walk, or play modes. +- `EditorApp` (function): function EditorApp({ gameId, playable, layers, save, modeChip }: EditorAppProps): React.JSX.Element — Top-level scene editor: author spawns/zones/paths/notes visually over edit, walk, or play modes. - `EditorAppProps` (interface): interface EditorAppProps — Props for mounting the scene editor over a playable game. - `EditorAssetEntry` (interface): interface EditorAssetEntry — A searchable, placeable asset shown in the editor's asset browser panel. - `EditorAssetInfo` (interface): interface EditorAssetInfo — A placeable asset entry offered in the editor's asset browser. @@ -66,7 +66,7 @@ - `EditorBridgeResponse` (type): type EditorBridgeResponse = { ok: boolean; result?: unknown; error?: string; } — Result envelope returned by every editor host RPC call. - `EditorBridgeServer` (interface): interface EditorBridgeServer — A running editor bridge server: its bound port, URL, and a stop handle. - `EditorBridgeServerOptions` (interface): interface EditorBridgeServerOptions — Options for starting the editor's HTTP bridge server: host api, port, hostname. -- `EditorCameraDriver` (function): function EditorCameraDriver({ api }: { api: EditorHostApi }): null — Smoothly pans the orbit camera to the editor host's focus target when it changes. +- `EditorCameraDriver` (const): const EditorCameraDriver: React.MemoExoticComponent<({ api }: { api: EditorHostApi; }) => null> — Smoothly pans the orbit camera to the editor host's focus target when it changes. - `EditorChrome` (function): function EditorChrome({ gameId, session, api, assets, ui, baselineJson, save, }: { gameId: string; session: EditorSession; api: EditorHostApi; assets: readonly EditorAssetEntry[]; ui: EditorUiStore; baselineJson?: string; save?: (json: string) => Promise<{ ok: boolean; path?: string; error?: string … — The full editor UI shell — toolbar, left panels (outliner/prefabs/sets/layers), viewport overlays, the selector-subscribed {@link InspectorPanel}, and the asset browser — wired to the session, UI store, and host RPC. Mounted by `EditorApp`; not a game-author entry point. - `EditorHostApi` (interface): interface EditorHostApi — The live editor's global control surface — session, visibility, camera focus, assets, mode, RPC. - `EditorLayerOverlays` (function): function EditorLayerOverlays({ document, visibility, selection, onSelect, activePathPoint, groundHeightAt, }: { document: EditorDocument; visibility: EditorKindVisibility; selection: readonly string[]; onSelect: (id: string) => void; activePathPoint?: { pathId: string; index: number } | null; ground… — Renders every visible marker, volume, path, and note from a document as in-scene 3D gizmos. @@ -80,10 +80,10 @@ - `GizmoMode` (type): type GizmoMode = "translate" | "rotate" | "scale" — Which transform gizmo is active for the current selection. - `PaintSettings` (interface): interface PaintSettings — Live terrain material-paint controls driven by the terrain tool panel. - `PathDraftPreview` (function): function PathDraftPreview({ points }: { points: readonly EditorVec3[] }): React.JSX.Element — Live preview of an in-progress path drawing: placed points and the connecting line. -- `PerfProbe` (function): function PerfProbe({ api }: { api: EditorHostApi }): null — In-canvas frame counter: publishes fps/draw-call samples to the editor host every 500ms. +- `PerfProbe` (const): const PerfProbe: React.MemoExoticComponent<({ api }: { api: EditorHostApi; }) => null> — In-canvas frame counter: publishes fps/draw-call samples to the editor host every 500ms. - `PlacementTool` (type): type PlacementTool = | { tool: "marker"; kind: string } | { tool: "volume"; kind: string; shape: EditorVolumeShape } | { tool: "note" } | { tool: "path"; kind: string } — The active creation tool — what a viewport click places next. - `SculptSettings` (interface): interface SculptSettings — Live terrain-brush controls driven by the terrain tool panel. -- `SelectionGizmo` (function): function SelectionGizmo({ session, ui, groundSnap, }: { session: EditorSession; ui: EditorUiStore; groundSnap?: (x: number, z: number) => number; }): React.JSX.Element | null — Drag-to-transform gizmo bound to the current selection, dispatching editor commands on release. Translating with a multi-selection moves every selected object by the drag delta; scaling a volume resizes its true shape (radius, height, or box half-extents); a selected path vertex moves just that point. Snapping follows the UI store: terrain height, grid quantization, or free movement. +- `SelectionGizmo` (const): const SelectionGizmo: React.MemoExoticComponent<({ session, ui, groundSnap, }: { session: EditorSession; ui: EditorUiStore; groundSnap?: ((x: number, z: number) => number) | undefined; }) => React.JSX.Element | null> — Drag-to-transform gizmo bound to the current selection, dispatching editor commands on release. Translating with a multi-selection moves every selected object by the drag delta; scaling a volume resizes its true shape (radius, height, or box half-extents); a selected path vertex moves just that point. Snapping follows the UI store: terrain height, grid quantization, or free movement. - `SnapMode` (type): type SnapMode = "ground" | "grid" | "off" — How gizmo drags land: stick to terrain height, quantize to a grid, or free. - `StandaloneAsset` (interface): interface StandaloneAsset — One user-supplied model the standalone editor can place: a stable id and a resolvable URL. - `StandaloneEditor` (function): function StandaloneEditor({ sceneId = "standalone", scene, assets, world, save, hidePickers = false, }: StandaloneEditorProps): React.JSX.Element — The scene editor, mounted over a blank gameless world instead of a game — the same `EditorApp` every jgengine game ships, usable standalone on the user's own project (CLI `jgengine editor`, desktop app, or any React host). Ships a slim strip to open a world file and pull in an asset folder; both are also settable up front through props. @@ -94,7 +94,7 @@ - `TerrainBrushKind` (type): type TerrainBrushKind = "raise" | "lower" | "smooth" | "flatten" | "noise" | "ramp" — A heightfield sculpt brush the terrain tool can apply. - `TerrainMaterial` (interface): interface TerrainMaterial — A paintable terrain material layer — a surface id plus the color it renders as. - `TerrainMode` (type): type TerrainMode = "sculpt" | "paint" — The terrain tool's active sub-mode: reshape the heightfield, or paint material layers onto it. -- `ViewportSelect` (function): function ViewportSelect({ api, ui }: { api: EditorHostApi; ui: EditorUiStore }): null — Canvas click-to-select and click-to-place. Document objects pick by screen proximity (registration always matches what you see) with click-cycling through stacked candidates and shift/ctrl additive selection; everything else picks by occlusion-ordered raycast against the tagged scene graph. When a placement tool is armed, clicks author new markers, volumes, notes, or path points at the ground hit instead of selecting. +- `ViewportSelect` (const): const ViewportSelect: React.MemoExoticComponent<({ api, ui }: { api: EditorHostApi; ui: EditorUiStore; }) => null> — Canvas click-to-select and click-to-place. Document objects pick by screen proximity (registration always matches what you see) with click-cycling through stacked candidates and shift/ctrl additive selection; everything else picks by occlusion-ordered raycast against the tagged scene graph. When a placement tool is armed, clicks author new markers, volumes, notes, or path points at the ground hit instead of selecting. - `VirtualWindow` (interface): interface VirtualWindow — The visible slice of a fixed-row-height list: which rows to mount and the spacer geometry. - `assetsFromCatalog` (function): function assetsFromCatalog(ids: readonly string[], resolve?: (id: string) => { url?: string } | null): EditorAssetEntry[] — Turns a game's asset catalog ids into editor asset entries for the browser panel. - `blankWorld` (function): function blankWorld(seed = "standalone"): EnvironmentWorldFeature — The default flat-ground world the standalone editor opens on when the host supplies none. @@ -124,13 +124,13 @@ ## @jgengine/editor/EditorApp -- `EditorApp` (function): function EditorApp({ gameId, playable, layers, save }: EditorAppProps): React.JSX.Element — Top-level scene editor: author spawns/zones/paths/notes visually over edit, walk, or play modes. +- `EditorApp` (function): function EditorApp({ gameId, playable, layers, save, modeChip }: EditorAppProps): React.JSX.Element — Top-level scene editor: author spawns/zones/paths/notes visually over edit, walk, or play modes. - `EditorAppProps` (interface): interface EditorAppProps — Props for mounting the scene editor over a playable game. - `EditorSaveFn` (type): type EditorSaveFn = (json: string) => Promise<{ ok: boolean; path?: string; error?: string }> — Persists an exported document JSON; resolves with where it landed or why it failed. ## @jgengine/editor/EditorCameraDriver -- `EditorCameraDriver` (function): function EditorCameraDriver({ api }: { api: EditorHostApi }): null — Smoothly pans the orbit camera to the editor host's focus target when it changes. +- `EditorCameraDriver` (const): const EditorCameraDriver: React.MemoExoticComponent<({ api }: { api: EditorHostApi; }) => null> — Smoothly pans the orbit camera to the editor host's focus target when it changes. ## @jgengine/editor/EditorChrome @@ -138,7 +138,7 @@ ## @jgengine/editor/PerfProbe -- `PerfProbe` (function): function PerfProbe({ api }: { api: EditorHostApi }): null — In-canvas frame counter: publishes fps/draw-call samples to the editor host every 500ms. +- `PerfProbe` (const): const PerfProbe: React.MemoExoticComponent<({ api }: { api: EditorHostApi; }) => null> — In-canvas frame counter: publishes fps/draw-call samples to the editor host every 500ms. ## @jgengine/editor/SchemaInspector @@ -147,8 +147,8 @@ ## @jgengine/editor/SelectionGizmo - `GizmoMode` (type): type GizmoMode = "translate" | "rotate" | "scale" — Which transform gizmo is active for the current selection. -- `SelectionGizmo` (function): function SelectionGizmo({ session, ui, groundSnap, }: { session: EditorSession; ui: EditorUiStore; groundSnap?: (x: number, z: number) => number; }): React.JSX.Element | null — Drag-to-transform gizmo bound to the current selection, dispatching editor commands on release. Translating with a multi-selection moves every selected object by the drag delta; scaling a volume resizes its true shape (radius, height, or box half-extents); a selected path vertex moves just that point. Snapping follows the UI store: terrain height, grid quantization, or free movement. -- `ViewportSelect` (function): function ViewportSelect({ api, ui }: { api: EditorHostApi; ui: EditorUiStore }): null — Canvas click-to-select and click-to-place. Document objects pick by screen proximity (registration always matches what you see) with click-cycling through stacked candidates and shift/ctrl additive selection; everything else picks by occlusion-ordered raycast against the tagged scene graph. When a placement tool is armed, clicks author new markers, volumes, notes, or path points at the ground hit instead of selecting. +- `SelectionGizmo` (const): const SelectionGizmo: React.MemoExoticComponent<({ session, ui, groundSnap, }: { session: EditorSession; ui: EditorUiStore; groundSnap?: ((x: number, z: number) => number) | undefined; }) => React.JSX.Element | null> — Drag-to-transform gizmo bound to the current selection, dispatching editor commands on release. Translating with a multi-selection moves every selected object by the drag delta; scaling a volume resizes its true shape (radius, height, or box half-extents); a selected path vertex moves just that point. Snapping follows the UI store: terrain height, grid quantization, or free movement. +- `ViewportSelect` (const): const ViewportSelect: React.MemoExoticComponent<({ api, ui }: { api: EditorHostApi; ui: EditorUiStore; }) => null> — Canvas click-to-select and click-to-place. Document objects pick by screen proximity (registration always matches what you see) with click-cycling through stacked candidates and shift/ctrl additive selection; everything else picks by occlusion-ordered raycast against the tagged scene graph. When a placement tool is armed, clicks author new markers, volumes, notes, or path points at the ground hit instead of selecting. ## @jgengine/editor/StandaloneEditor @@ -160,6 +160,21 @@ - `createBlankPlayable` (function): function createBlankPlayable(options: BlankPlayableOptions = {}): PlayableGame — Builds a minimal gameless `PlayableGame` — a flat world plus an asset catalog — for the editor to mount over. - `downloadSaver` (function): function downloadSaver(filename = "editor.scene.json"): EditorSaveFn — A save fn that hands the scene JSON back to the browser as a downloaded file — the exit path when no dev server is listening. +## @jgengine/editor/TerrainPanel + +- `TerrainPanel` (function): function TerrainPanel({ session, ui }: { session: EditorSession; ui: EditorUiStore }): React.JSX.Element — The terrain-tool panel: create/clear the heightfield and drive the sculpt/paint controls. + +## @jgengine/editor/chromeFields + +- `NumberField` (function): function NumberField({ label, value, onCommit, step = 1, }: { label: string; value: number; onCommit: (value: number) => void; step?: number; }): React.JSX.Element — ⚠ undocumented +- `SliderRow` (function): function SliderRow({ label, value, min, max, step, onChange, format, }: { label: string; value: number; min: number; max: number; step: number; onChange: (value: number) => void; format?: (value: number) => string; }): React.JSX.Element — ⚠ undocumented + +## @jgengine/editor/chromeStyles + +- `BTN` (const): const BTN: "rounded-md bg-white/[0.04] px-2 py-1 text-neutral-300 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/10 hover:text-neutral-100" — ⚠ undocumented +- `INPUT` (const): const INPUT: "rounded-md border border-white/10 bg-black/40 px-2 py-1 outline-none transition-colors placeholder:text-neutral-600 focus:border-cyan-400/60 focus:bg-black/60" — ⚠ undocumented +- `MICRO` (const): const MICRO: "text-[9px] font-semibold uppercase tracking-[0.14em] text-neutral-500" — ⚠ undocumented + ## @jgengine/editor/mcp/bridgeServer - `EditorBridgeServer` (interface): interface EditorBridgeServer — A running editor bridge server: its bound port, URL, and a stop handle. @@ -171,7 +186,15 @@ ## @jgengine/editor/mcp/loadGameLayers -- `loadGameLayers` (function): function loadGameLayers(gameId: string): Promise — Node-only: resolve a game's `editorLayers` export straight from Games//src. +- `LoadGameLayersResult` (type): type LoadGameLayersResult = | { ok: true; document: EditorDocument } | { ok: false; errors: EditorDocumentDiagnostic[] } — Result of {@link loadGameLayers}: a validated document, or every diagnostic collected while decoding it. +- `decodeGameLayers` (function): function decodeGameLayers(resolved: unknown): LoadGameLayersResult — Validates an already-resolved `editorLayers` export value (post module-load, post factory-call) against the editor document schema — the exact check {@link loadGameLayers} applies at the untrusted-input boundary between game-authored code and a live editor session. +- `loadGameLayers` (function): function loadGameLayers(gameId: string): Promise — Node-only: resolves a game's `editorLayers` export straight from Games//src and validates its shape before it reaches a live editor session — the untrusted-input boundary between game-authored code and the engine. + +## @jgengine/editor/mcp/rpcRequest + +- `DecodeRpcRequestResult` (type): type DecodeRpcRequestResult = | { ok: true; request: EditorBridgeRequest } | { ok: false; errors: RpcRequestDiagnostic[] } — Result of {@link decodeEditorBridgeRequest}: a request whose `method` is a real one, or the diagnostic that rejected it. +- `RpcRequestDiagnostic` (interface): interface RpcRequestDiagnostic — One field-level failure surfaced while decoding an untrusted RPC request. +- `decodeEditorBridgeRequest` (function): function decodeEditorBridgeRequest(raw: unknown): DecodeRpcRequestResult — Validates an untrusted JSON-decoded RPC payload (from `--rpc` or the HTTP bridge) before it reaches `EditorHostApi.handle`: confirms it is a plain object carrying a known `method` name. Per-method field shape is still enforced by `handle`'s own dispatch, but a garbled or unknown-method payload is rejected here with a path-specific diagnostic instead of flowing through on a blind cast. ## @jgengine/editor/mcp/stdioServer @@ -201,13 +224,9 @@ - `EditorAssetInfo` (interface): interface EditorAssetInfo — A placeable asset entry offered in the editor's asset browser. - `EditorBridgeRequest` (type): type EditorBridgeRequest = | { method: "editor_status" } | { method: "set_mode"; mode: EditorRunMode } | { method: "perf_report" } | { method: "list_layers" } | { method: "list_selection" } | { method: "get_marker"; id: string } | { method: "get_volume"; id: string } | { method: "set_transform"; id:… — RPC request shapes the editor host understands, used by the MCP bridge and UI. - `EditorBridgeResponse` (type): type EditorBridgeResponse = { ok: boolean; result?: unknown; error?: string; } — Result envelope returned by every editor host RPC call. -- `EditorDocument` (interface): interface EditorDocument — The full authored scene: every marker, volume, path, note, and sculpted terrain for a game. - `EditorHostApi` (interface): interface EditorHostApi — The live editor's global control surface — session, visibility, camera focus, assets, mode, RPC. -- `EditorKindVisibility` (interface): interface EditorKindVisibility — Per-kind show/hide flags for the editor's layer panel. - `EditorPerfSample` (interface): interface EditorPerfSample — Rolling frame-rate sample published by the in-canvas PerfProbe. - `EditorRunMode` (type): type EditorRunMode = "edit" | "walk" | "play" — How the editor hosts the game: frozen placement view, roamable world, or the real game. -- `EditorSession` (interface): interface EditorSession — Stateful, undoable handle for driving scene edits from UI or an MCP agent. -- `EditorSessionState` (interface): interface EditorSessionState — The document plus current selection at a point in editor history. - `createEditorHost` (function): function createEditorHost(options: { gameId: string; layers: EditorLayersInput | undefined; assets?: readonly EditorAssetInfo[]; onFocus?: (target: { x: number; y: number; z: number } | null) => void; }): { session: EditorSession; api: EditorHostApi; dispose: () => void; } — Builds and installs an editor host for a game: session, visibility, assets, and RPC handling. - `getEditorHost` (function): function getEditorHost(): EditorHostApi | null — Retrieves the globally installed editor host, or null if none is mounted. - `installEditorHost` (function): function installEditorHost(api: EditorHostApi): () => void — Publishes an editor host globally so devtools and MCP agents can reach it; returns a cleanup fn. diff --git a/.claude/skills/jgengine-gameplay/SKILL.md b/.claude/skills/jgengine-gameplay/SKILL.md index 293c7ad2d..4d801700f 100644 --- a/.claude/skills/jgengine-gameplay/SKILL.md +++ b/.claude/skills/jgengine-gameplay/SKILL.md @@ -1,4 +1,4 @@ ---- +--- name: jgengine-gameplay description: Gameplay systems: items, quests, economy, crafting, turns, objectives. --- @@ -149,10 +149,6 @@ Config: `persist: true` or `persist: { mode?: "autosave" | "manual", storage?: " Offline↔cloud is the same one-line swap as `createSaveStore`: the shell can inject a Convex backend via the `createGameContext({ save })` seam instead of the default `localStorage` one — game code unchanged. `createRuntimeSave({ target, backend, mode, ... })` is the underlying bridge if you need to wire it by hand (`target` is any `{ snapshot, hydrate, subscribe }` — a `GameContext` satisfies it). The save captures economy/quest/unlocks/roster automatically now (via `snapshotAll`/`hydrateAll` on each subsystem) — the only state left out of the snapshot is a **module-level singleton** (a bare `let` in a game module), which escapes both save and replication: re-derive it from the restored `defineStore` slots/stats after `load()` (see the-robots' `resumeBuild`, which rebuilds its `activeCharacter`/`talentTree` singletons from the restored `characterId`/`talentRanks` stores). -## Race sessions - -`@jgengine/core/game/race` layers a start-line lifecycle and results math over the existing `createRaceState`/`createLapTimer` position tracker. `idleRaceSession()` is the pre-race grid state; `startRaceCountdown({ seconds? })` drops the lights into a `countdown` phase (or straight to `racing` for a standing start); `tickRaceSession(session, dt)` bleeds the countdown and accumulates `elapsed` while racing; `finishRaceSession(session)` freezes it at the flag. Once a finish order exists, `racePlacements(finishOrder, options?)` turns it into every racer's 1-based `place` + win/lose `outcome`, `placementOf(finishOrder, racerId, options?)` reads one racer's placement, and `raceOutcomeOf(finishOrder, racerId, options?)` is the plain win/lose shortcut — all three share a `winningPlaces` cutoff (default 1, pass 3 for a podium finish) instead of a hand-rolled `ranking[0] === player` check. - ## Combat — effects, projectiles, death, feel, abilities ## Card, board & shaped-inventory primitives 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. diff --git a/.claude/skills/jgengine-gameplay/api.md b/.claude/skills/jgengine-gameplay/api.md index 200203ef9..acdd708f9 100644 --- a/.claude/skills/jgengine-gameplay/api.md +++ b/.claude/skills/jgengine-gameplay/api.md @@ -230,7 +230,7 @@ ## @jgengine/core/game/connectedPlayers -- `ConnectedPlayer` (interface): interface ConnectedPlayer — A player currently joined to a hosted world — the unit a shared-world loop iterates instead of `ctx.player`. +- `ConnectedPlayer` (interface): interface ConnectedPlayer — A player currently joined to a hosted world — the unit a shared-world loop iterates instead of `ctx.player`. Frozen by the registry (see {@link ConnectedPlayers.get}); fields are `readonly` so a caller can't edit its own copy and assume the change stuck. - `ConnectedPlayers` (interface): interface ConnectedPlayers — The set of players connected to one hosted world. A single-player game uses `ctx.player`; a shared-world loop reads `ctx.game.players` so `onTick` can advance every connected hero, not just the one local player. The host (`HostedGameRunner`) drives `join`/`leave`/`setInput`; game code reads `list`/`ids`/`has`/`count`/`input`. - `createConnectedPlayers` (function): function createConnectedPlayers(): ConnectedPlayers — Build an empty {@link ConnectedPlayers} registry — the host joins/leaves players; the game loop reads them. @@ -281,7 +281,7 @@ - `CombatTelegraphEvent` (interface): interface CombatTelegraphEvent — ⚠ undocumented - `CombatVfxEvent` (interface): interface CombatVfxEvent — A transient sprite-particle effect the shell renders once and expires — one burst of `kind`, tinted `color`, anchored at `from` (and `to` for travel/beam effects). - `CosmeticsChangedEvent` (interface): interface CosmeticsChangedEvent — ⚠ undocumented -- `DeathReason` (type): type DeathReason = | { kind: "player_kill"; killerUserId: string; via?: { item?: string } } | { kind: "environment"; source: string } | { kind: "self"; source: string } — ⚠ undocumented +- `DeathReason` (type): type DeathReason = | { kind: "player_kill"; killerUserId: string; via?: { item?: string } } | { kind: "environment"; source: string } | { kind: "self"; source: string } — Why an entity died — who or what gets credit, for drop/command rules and the `entity.died` event. - `EmotePlayedEvent` (interface): interface EmotePlayedEvent — ⚠ undocumented - `EntityAnimationEvent` (interface): interface EntityAnimationEvent — Request that an entity's rig play a one-shot animation clip bound to `event` in its `animation.oneShots` (e.g. an "attack" swing); the shell resolves the clip and plays it once over the locomotion state. - `EntityDiedEvent` (interface): interface EntityDiedEvent — ⚠ undocumented @@ -928,9 +928,12 @@ ## @jgengine/core/random/rng +- `RandomSeed` (type): type RandomSeed = number & { readonly __randomSeed: unique symbol } — Opaque, serializable PRNG cursor for state machines that must persist their own random stream (a spawn director, a heat/pursuit meter) instead of holding a closure — the state round-trips through save/load and multiplayer sync, so it can't carry a function. Never read or do arithmetic on the raw value directly; thread it through {@link stepRandomSeed} only. - `hashString` (function): function hashString(text: string): number — Deterministic 32-bit FNV-1a hash of a string → unsigned int. Same text, same number, on every platform — the stable seed behind per-id jitter, spread offsets, and content-addressed variation. +- `randomSeedFrom` (function): function randomSeedFrom(seed: number): RandomSeed — Wraps an already-integer seed (e.g. a `config.seed`) as a {@link RandomSeed} — no hashing. - `seededRng` (function): function seededRng(seed: string | number): () => number — Deterministic pseudo-random generator seeded from a string or number — same seed, same sequence. - `seededStreams` (function): function seededStreams(seed: string | number): (stream: string) => () => number — Derives independent, deterministic {@link seededRng} streams from one base seed, keyed by stream name. +- `stepRandomSeed` (function): function stepRandomSeed(seed: RandomSeed): readonly [value: number, next: RandomSeed] — One step of the {@link seededRng} recurrence in pure (seed in, seed out) form — the same mulberry32-style generator, shared by every state machine that persists its own PRNG cursor instead of closing over a generator. ## @jgengine/core/random/seedLink @@ -986,6 +989,37 @@ - `RoundState` (interface): interface RoundState — ⚠ undocumented - `RoundTeam` (interface): interface RoundTeam — A team entry with an optional role tag (e.g. "attacker", "defender") retrievable via `RoundState.roleOf`. +## @jgengine/core/survival/decayMeter + +- `DecayMeterConfig` (interface): interface DecayMeterConfig — ⚠ undocumented +- `DecayMeterSet` (interface): interface DecayMeterSet — ⚠ undocumented +- `DecayMeterState` (interface): interface DecayMeterState — ⚠ undocumented +- `MeterThreshold` (interface): interface MeterThreshold — ⚠ undocumented +- `createDecayMeterSet` (function): function createDecayMeterSet(configs: readonly DecayMeterConfig[]): DecayMeterSet — Named decay meters — hunger, thirst, oxygen, sanity, warmth, stamina. Each drains (or recovers) on game-time `dt` at a configurable rate, refills from consumables or actions, and raises moodle statuses at thresholds. Rate modifiers let the environment drive them (colder → faster warmth loss; toxic biome → oxygen drops), so a game reads an environment field then calls `setRateModifier`. + +## @jgengine/core/survival/moodle + +- `MOODLE_SEVERITY_ORDER` (const): const MOODLE_SEVERITY_ORDER: Record — ⚠ undocumented +- `Moodle` (interface): interface Moodle — ⚠ undocumented +- `MoodleSeverity` (type): type MoodleSeverity = "good" | "neutral" | "warning" | "critical" — ⚠ undocumented +- `MoodleSource` (type): type MoodleSource = "meter" | "ailment" | "buff" — ⚠ undocumented +- `MoodleStack` (interface): interface MoodleStack — ⚠ undocumented +- `TimedMoodleInput` (interface): interface TimedMoodleInput — ⚠ undocumented +- `createMoodleStack` (function): function createMoodleStack(): MoodleStack — A stateful holder for timed status moodles (food buffs, temporary shelter, warmth). Meters and multi-region health derive their own moodles on read; combine all three through `stackMoodles(stack.list(), meterMoodles, ailmentMoodles)` for one display. +- `stackMoodles` (function): function stackMoodles(...groups: readonly (readonly Moodle[])[]): Moodle[] — Merge any number of moodle groups into one stack — meters, ailments, and buffs share this display. Same-id moodles fold together (stacks add, worst severity wins); the result is ordered worst-first so the HUD reads critical statuses at a glance. + +## @jgengine/core/survival/regionHealth + +- `AilmentConfig` (interface): interface AilmentConfig — ⚠ undocumented +- `AilmentInstance` (interface): interface AilmentInstance — ⚠ undocumented +- `DamageResult` (interface): interface DamageResult — ⚠ undocumented +- `HealthRegionConfig` (interface): interface HealthRegionConfig — ⚠ undocumented +- `MultiRegionHealth` (interface): interface MultiRegionHealth — ⚠ undocumented +- `MultiRegionHealthConfig` (interface): interface MultiRegionHealthConfig — ⚠ undocumented +- `RegionHealthState` (interface): interface RegionHealthState — ⚠ undocumented +- `TreatResult` (interface): interface TreatResult — ⚠ undocumented +- `createMultiRegionHealth` (function): function createMultiRegionHealth(config: MultiRegionHealthConfig): MultiRegionHealth — Per-region/limb health tracked separately, so each body part takes and heals damage on its own. + ## @jgengine/core/turn/commit - `CommitController` (interface): interface CommitController — ⚠ undocumented diff --git a/.claude/skills/jgengine-gameplay/capabilities.md b/.claude/skills/jgengine-gameplay/capabilities.md index ed211ee8a..a6da7d08b 100644 --- a/.claude/skills/jgengine-gameplay/capabilities.md +++ b/.claude/skills/jgengine-gameplay/capabilities.md @@ -16,9 +16,9 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `createCosmetics` (function) · `import { createCosmetics } from "@jgengine/core/game/cosmetics"` -## currency-format — format a currency amount with its symbol for display +## decay-meter — survival meters that drain/refill over game time (hunger, water, oxygen, stamina) -- `formatCurrencyAmount` (function) · `import { formatCurrencyAmount } from "@jgengine/core/economy/currency"` +- `createDecayMeterSet` (function) · `import { createDecayMeterSet } from "@jgengine/core/survival/decayMeter"` ## dialogue-bridge — open/close the talkable→DialogueBox flow with no per-game store or command glue @@ -40,19 +40,11 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `createSaveStore` (function) · `import { createSaveStore } from "@jgengine/core/game/saveStore"` -## inventory-grid — a bag of stackable items with add, remove, and move - -- `createEmptyInventory` (function) · `import { createEmptyInventory } from "@jgengine/core/inventory/inventoryModel"` - ## item-instance-registry — a runtime store for procedurally generated item instances - `createItemInstanceRegistry` (function) · `import { createItemInstanceRegistry } from "@jgengine/core/item/itemInstanceRegistry"` - `proceduralLootEntry` (function) · `import { proceduralLootEntry } from "@jgengine/core/item/itemInstanceRegistry"` -## lane-board — a lane-based card-battler board with per-lane outcomes - -- `createLaneBoard` (function) · `import { createLaneBoard } from "@jgengine/core/board/laneBoard"` - ## lap-splits — per-lap durations from a cumulative split book - `lapDurations` (function) · `import { lapDurations } from "@jgengine/core/game/race"` @@ -71,6 +63,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `LifecycleConfig` (interface) · `import { LifecycleConfig } from "@jgengine/core/game/defineGame"` +## limb-health — per-body-part/region health tracked separately + +- `createMultiRegionHealth` (function) · `import { createMultiRegionHealth } from "@jgengine/core/survival/regionHealth"` + ## listing-book — player-driven marketplace listings with a house cut, expiry sweep, and seller collection box - `createListingBook` (function) · `import { createListingBook } from "@jgengine/core/economy/listingBook"` @@ -92,10 +88,6 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `createLootRegistry` (function) · `import { createLootRegistry } from "@jgengine/core/game/lootTable"` - `lootTable` (function) · `import { lootTable } from "@jgengine/core/game/lootTable"` -## match-rounds — run buy/action/end round phases with per-round economy - -- `createRoundState` (function) · `import { createRoundState } from "@jgengine/core/session/roundState"` - ## modular-item — attach parts into item mount slots to compute combined stats - `slotAccepts` (function) · `import { slotAccepts } from "@jgengine/core/item/modularItem"` @@ -133,22 +125,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `createRaceState` (function) · `import { createRaceState } from "@jgengine/core/game/race"` -## role-assign — assign hidden or team roles to players by ratio - -- `assignRoles` (function) · `import { assignRoles } from "@jgengine/core/session/roles"` - ## run-modifiers — a roguelike run built from stacking drafted modifier picks - `createRunDraft` (function) · `import { createRunDraft } from "@jgengine/core/game/runDraft"` -## shared-wallet — shared/group currency pools tracking per-member contributions - -- `createWalletBook` (function) · `import { createWalletBook } from "@jgengine/core/economy/sharedWallet"` - -## shop-trade — buy and sell goods against player currency balances - -- `createTradeSystem` (function) · `import { createTradeSystem } from "@jgengine/core/game/trade"` - ## social-emotes — emotes and social interactions between nearby players - `createSocial` (function) · `import { createSocial } from "@jgengine/core/game/social"` @@ -157,18 +137,6 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `createSpawnPoints` (function) · `import { createSpawnPoints } from "@jgengine/core/game/spawnPoints"` -## tech-tree — research nodes with prerequisites that unlock recipes - -- `availableTech` (function) · `import { availableTech } from "@jgengine/core/economy/techTree"` - -## tetris-inventory — a spatial grid inventory holding shaped multi-cell items - -- `createShapedGrid` (function) · `import { createShapedGrid } from "@jgengine/core/inventory/shapedGrid"` - -## timeline-board — a step-sequencer timeline board of timed slots - -- `createTimelineBoard` (function) · `import { createTimelineBoard } from "@jgengine/core/board/timelineBoard"` - ## toast-feed — queue of transient self-expiring on-screen messages (toasts, announcer, kill-feed) - `appendToast` (function) · `import { appendToast } from "@jgengine/core/game/toasts"` @@ -193,7 +161,3 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p ## weighted-pick — pick one item from a set with an injected random source - `pickUniform` (function) · `import { pickUniform } from "@jgengine/core/random/pick"` - -## world-drops — spawn pickup-able items in the world, including death drops - -- `createWorldItemStore` (function) · `import { createWorldItemStore } from "@jgengine/core/game/worldItem"` diff --git a/.claude/skills/jgengine-multiplayer/SKILL.md b/.claude/skills/jgengine-multiplayer/SKILL.md index 24c641d84..34c2437ac 100644 --- a/.claude/skills/jgengine-multiplayer/SKILL.md +++ b/.claude/skills/jgengine-multiplayer/SKILL.md @@ -1,4 +1,4 @@ ---- +--- name: jgengine-multiplayer description: Multiplayer API: adapters, topology, authority, rooms, persistence. --- @@ -70,7 +70,7 @@ For single-player (or per-user) game saves that live on the server instead of `l ## Offline whole-world save — `defineGame({ persist })` / `ctx.game.save` -The single-player counterpart to hosted persistence. A multiplayer host serializes the whole world into a `WorldSnapshot` (`ctx.snapshot()`/`ctx.hydrate()`) to replicate it; the **same seam** persists an offline game to `localStorage`. Turn on `defineGame({ persist: true })` (wired only when `isOffline(multiplayer)` — never for a server-authoritative world, where the host persists) and the engine binds `ctx.game.save` (a `RuntimeSave`) to a local backend, autosaving the entire world — every `defineStore` slot, all entities + stats + inventories — with no per-field code. `createRuntimeSave({ target, backend, mode, ... })` (`@jgengine/core/runtime/runtimeSave`) is the bridge under it: `target` is any `{ snapshot, hydrate, subscribe }` (a `GameContext` satisfies it), `backend` is the same `SaveBackend` seam as `createSaveStore` — so swapping `localSaveBackend()` for `createConvexSaveBackend(...)` via the `createGameContext({ save })` seam moves an offline game's whole-world save to the cloud, unchanged. Modes: `"autosave"` (debounced, default) or `"manual"` + `checkpoint()` for save points / quest / area triggers. The game calls `ctx.game.save.load()` on boot to restore. Full authoring guide: `jgengine-gameplay` → "Save the *whole* game automatically". +The single-player counterpart to hosted persistence, over the **same seam**: a multiplayer host serializes the world into a `WorldSnapshot` (`ctx.snapshot()`/`ctx.hydrate()`) to replicate it; `defineGame({ persist: true })` persists that snapshot to a local backend instead (wired only when `isOffline(multiplayer)` — never for a server-authoritative world, where the host persists). Swapping `localSaveBackend()` for `createConvexSaveBackend(...)` via `createGameContext({ save })` moves the save to the cloud unchanged. Full authoring guide (modes, save points, slots): `jgengine-gameplay` → "Save the *whole* game automatically". ## Hosted-world persistence and clean shutdown — `@jgengine/node/worldServer`, `@jgengine/node/shutdown` diff --git a/.claude/skills/jgengine-multiplayer/api.md b/.claude/skills/jgengine-multiplayer/api.md index 4b407c638..2b9f0437d 100644 --- a/.claude/skills/jgengine-multiplayer/api.md +++ b/.claude/skills/jgengine-multiplayer/api.md @@ -203,28 +203,31 @@ ## @jgengine/node -- `GameHost` (type): type GameHost = { joinServer: (args: { userId: string; gameId: string; serverId?: string; attributes?: SessionAttributes; }) => Promise; browseServers: (args: { gameId: string; filter?: MatchFilter; limit?: number; }) => Promise; joinByCode: (args: { userId: strin… — A transport-agnostic authoritative game server host that manages sessions, ticking, and persistence. -- `GameHostOptions` (type): type GameHostOptions = { runtimes?: GameRuntime[]; persistence: HostPersistence; tickMs?: number; slotsPerServer?: number; now?: () => number; createServerId?: () => string; allowedFeedActions?: readonly string[]; } — Configuration for {@link createGameHost}, including persistence, tick rate, and game runtimes. +- `DEFAULT_HEARTBEAT_INTERVAL_MS` (const): const DEFAULT_HEARTBEAT_INTERVAL_MS: 30000 — Default ping/pong interval; a socket that misses one round-trip is terminated. +- `DEFAULT_MAX_CONNECTIONS` (const): const DEFAULT_MAX_CONNECTIONS: 10000 — Default max concurrent sockets this server accepts before rejecting new ones. +- `DEFAULT_MAX_PAYLOAD_BYTES` (const): const DEFAULT_MAX_PAYLOAD_BYTES: 1048576 — Default per-message payload cap (bytes) — `ws` closes the socket with 1009 past this. - `GameSocketIoServer` (type): type GameSocketIoServer = { rewind: (args: { serverId: string; atMs: number }) => RewoundPosition[]; close: () => void; } — ⚠ undocumented - `GameSocketIoServerOptions` (type): type GameSocketIoServerOptions = HostRouterOptions & { io: SocketIoLikeServer } — ⚠ undocumented - `GameWsServer` (type): type GameWsServer = { wss: WebSocketServer; port: () => number; rewind: (args: { serverId: string; atMs: number }) => RewoundPosition[]; close: () => Promise; } — ⚠ undocumented -- `GameWsServerOptions` (type): type GameWsServerOptions = HostRouterOptions & { server?: HttpServer; port?: number; path?: string; } — ⚠ undocumented -- `HostChangeEvent` (type): type HostChangeEvent = { type: "server"; serverId: string; } | { type: "player"; serverId: string; userId: string; } | { type: "feed"; serverId: string; action: string; } — A change notification emitted by a `GameHost` for a server, player, or feed mutation. +- `GameWsServerOptions` (type): type GameWsServerOptions = HostRouterOptions & { server?: HttpServer; port?: number; path?: string; /** Per-message payload cap in bytes. Defaults to {@link DEFAULT_MAX_PAYLOAD_BYTES}. */ maxPayloadBytes?: number; /** Max concurrent sockets accepted; connections beyond this are closed immediately. D… — ⚠ undocumented - `HostedGameDefinition` (interface): interface HostedGameDefinition — A game the world server can host — its authoritative {@link GameDefinition} and the content lookup a `GameContext` reads. +- `InstallShutdownHookOptions` (interface): interface InstallShutdownHookOptions — Config for {@link installShutdownHook}. - `NodeHandler` (type): type NodeHandler = (req: IncomingMessage, res: ServerResponse) => void — ⚠ undocumented -- `RewoundPosition` (type): type RewoundPosition = { userId: string; x: number; y: number; z: number; } — A player's interpolated position sampled from history at a past timestamp. +- `ShutdownHook` (interface): interface ShutdownHook — A live signal listener installed by {@link installShutdownHook}; call `remove()` to uninstall it (tests, embedders opting out). - `SocketIoLikeServer` (type): type SocketIoLikeServer = { on: (event: "connection", listener: (socket: SocketIoLikeServerSocket) => void) => unknown; } — ⚠ undocumented - `SocketIoLikeServerSocket` (type): type SocketIoLikeServerSocket = { on: (event: string, listener: (payload: string) => void) => unknown; send: (data: string) => unknown; disconnect: (close?: boolean) => unknown; } — ⚠ undocumented - `WebHandler` (type): type WebHandler = (request: Request) => Promise — ⚠ undocumented - `WorldGameServer` (interface): interface WorldGameServer — A runnable ws host for GameContext worlds: {@link createWorldGameHost} + {@link createGameWsServer} + a tick loop, with a manual `tick(dt)` seam so a fake clock can drive it in tests. - `WorldGameServerOptions` (interface): interface WorldGameServerOptions extends Omit — Config for {@link createWorldGameServer}: how to resolve a game by id, the tick cadence, and the underlying ws-server/router options (minus `host`, which the server builds). +- `WorldPersistence` (interface): interface WorldPersistence — The persistence plug-point for {@link createWorldGameServer}: resolves one {@link HostedWorldStore} per hosted world, called once when the world host session is created. Structural — a SQL, file, or Convex-backed store all conform without `node` importing a concrete driver; only a `store()` factory is required. Mirrors `HostPersistence` (the reducer host's persistence seam). +- `WorldPersistenceKey` (interface): interface WorldPersistenceKey — Per-world key a {@link WorldPersistence} resolves a {@link HostedWorldStore} for. - `attachGameSocketIoServer` (function): function attachGameSocketIoServer(options: GameSocketIoServerOptions): GameSocketIoServer — ⚠ undocumented - `clearFilePersistence` (function): function clearFilePersistence(dir: string): Promise — ⚠ undocumented -- `createGameHost` (function): function createGameHost(options: GameHostOptions): GameHost — Creates a `GameHost` that runs game servers over the given persistence and runtimes. - `createGameWsServer` (function): function createGameWsServer(options: GameWsServerOptions): GameWsServer — ⚠ undocumented - `createWorldGameServer` (function): function createWorldGameServer(options: WorldGameServerOptions): WorldGameServer — Build a {@link WorldGameServer} — one process hosting authoritative GameContext worlds over ws, ready for two-client play once {@link WorldGameServer.start} runs. - `filePersistence` (function): function filePersistence(dir: string, now: () => number = Date.now): HostPersistence — ⚠ undocumented -- `memoryPersistence` (function): function memoryPersistence(now?: () => number): HostPersistence — Creates an in-memory `HostPersistence` implementation, useful for tests and ephemeral hosts. +- `installShutdownHook` (function): function installShutdownHook(shutdown: () => Promise | void, options: InstallShutdownHookOptions = {}): ShutdownHook — Wires `SIGINT`/`SIGTERM` (or a custom signal list) to a clean-shutdown callback — e.g. `() => worldServer.close()` or `() => Promise.all([wsServer.close(), host.stop()])`. Bounded by `timeoutMs` so a stuck flush can't hang the process forever; idempotent — a second signal delivered mid-shutdown reuses the same in-flight run instead of flushing twice. Returns a {@link ShutdownHook} whose `remove()` uninstalls the listeners, for tests and embedders that want their own handling. +- `memoryWorldPersistence` (function): function memoryWorldPersistence(): WorldPersistence — Default {@link WorldPersistence}: an isolated in-memory {@link HostedWorldStore} per `gameId`/`serverId`, lost on process exit. - `toNodeHandler` (function): function toNodeHandler(handler: WebHandler): NodeHandler — ⚠ undocumented - `toWebRequest` (function): function toWebRequest(req: IncomingMessage): Promise — ⚠ undocumented @@ -242,25 +245,24 @@ - `EditorManifest` (interface): interface EditorManifest — What the standalone editor loads on boot: the on-disk scene document (if any) and every model in the asset folder. - `EditorManifestAsset` (interface): interface EditorManifestAsset — One placeable model the standalone editor lists — a stable id and a URL the dev server serves it from. -## @jgengine/node/host - -- `GameHost` (type): type GameHost = { joinServer: (args: { userId: string; gameId: string; serverId?: string; attributes?: SessionAttributes; }) => Promise; browseServers: (args: { gameId: string; filter?: MatchFilter; limit?: number; }) => Promise; joinByCode: (args: { userId: strin… — A transport-agnostic authoritative game server host that manages sessions, ticking, and persistence. -- `GameHostOptions` (type): type GameHostOptions = { runtimes?: GameRuntime[]; persistence: HostPersistence; tickMs?: number; slotsPerServer?: number; now?: () => number; createServerId?: () => string; allowedFeedActions?: readonly string[]; } — Configuration for {@link createGameHost}, including persistence, tick rate, and game runtimes. -- `HostChangeEvent` (type): type HostChangeEvent = { type: "server"; serverId: string; } | { type: "player"; serverId: string; userId: string; } | { type: "feed"; serverId: string; action: string; } — A change notification emitted by a `GameHost` for a server, player, or feed mutation. -- `createGameHost` (function): function createGameHost(options: GameHostOptions): GameHost — Creates a `GameHost` that runs game servers over the given persistence and runtimes. -- `memoryPersistence` (function): function memoryPersistence(now?: () => number): HostPersistence — Creates an in-memory `HostPersistence` implementation, useful for tests and ephemeral hosts. - ## @jgengine/node/persistence +- `WorldPersistence` (interface): interface WorldPersistence — The persistence plug-point for {@link createWorldGameServer}: resolves one {@link HostedWorldStore} per hosted world, called once when the world host session is created. Structural — a SQL, file, or Convex-backed store all conform without `node` importing a concrete driver; only a `store()` factory is required. Mirrors `HostPersistence` (the reducer host's persistence seam). +- `WorldPersistenceKey` (interface): interface WorldPersistenceKey — Per-world key a {@link WorldPersistence} resolves a {@link HostedWorldStore} for. - `clearFilePersistence` (function): function clearFilePersistence(dir: string): Promise — ⚠ undocumented - `filePersistence` (function): function filePersistence(dir: string, now: () => number = Date.now): HostPersistence — ⚠ undocumented -- `memoryPersistence` (function): function memoryPersistence(now?: () => number): HostPersistence — Creates an in-memory `HostPersistence` implementation, useful for tests and ephemeral hosts. +- `memoryWorldPersistence` (function): function memoryWorldPersistence(): WorldPersistence — Default {@link WorldPersistence}: an isolated in-memory {@link HostedWorldStore} per `gameId`/`serverId`, lost on process exit. + +## @jgengine/node/shutdown + +- `InstallShutdownHookOptions` (interface): interface InstallShutdownHookOptions — Config for {@link installShutdownHook}. +- `ShutdownHook` (interface): interface ShutdownHook — A live signal listener installed by {@link installShutdownHook}; call `remove()` to uninstall it (tests, embedders opting out). +- `installShutdownHook` (function): function installShutdownHook(shutdown: () => Promise | void, options: InstallShutdownHookOptions = {}): ShutdownHook — Wires `SIGINT`/`SIGTERM` (or a custom signal list) to a clean-shutdown callback — e.g. `() => worldServer.close()` or `() => Promise.all([wsServer.close(), host.stop()])`. Bounded by `timeoutMs` so a stuck flush can't hang the process forever; idempotent — a second signal delivered mid-shutdown reuses the same in-flight run instead of flushing twice. Returns a {@link ShutdownHook} whose `remove()` uninstalls the listeners, for tests and embedders that want their own handling. ## @jgengine/node/socketIoServer - `GameSocketIoServer` (type): type GameSocketIoServer = { rewind: (args: { serverId: string; atMs: number }) => RewoundPosition[]; close: () => void; } — ⚠ undocumented - `GameSocketIoServerOptions` (type): type GameSocketIoServerOptions = HostRouterOptions & { io: SocketIoLikeServer } — ⚠ undocumented -- `RewoundPosition` (type): type RewoundPosition = { userId: string; x: number; y: number; z: number; } — A player's interpolated position sampled from history at a past timestamp. - `SocketIoLikeServer` (type): type SocketIoLikeServer = { on: (event: "connection", listener: (socket: SocketIoLikeServerSocket) => void) => unknown; } — ⚠ undocumented - `SocketIoLikeServerSocket` (type): type SocketIoLikeServerSocket = { on: (event: string, listener: (payload: string) => void) => unknown; send: (data: string) => unknown; disconnect: (close?: boolean) => unknown; } — ⚠ undocumented - `attachGameSocketIoServer` (function): function attachGameSocketIoServer(options: GameSocketIoServerOptions): GameSocketIoServer — ⚠ undocumented @@ -286,9 +288,11 @@ ## @jgengine/node/wsServer +- `DEFAULT_HEARTBEAT_INTERVAL_MS` (const): const DEFAULT_HEARTBEAT_INTERVAL_MS: 30000 — Default ping/pong interval; a socket that misses one round-trip is terminated. +- `DEFAULT_MAX_CONNECTIONS` (const): const DEFAULT_MAX_CONNECTIONS: 10000 — Default max concurrent sockets this server accepts before rejecting new ones. +- `DEFAULT_MAX_PAYLOAD_BYTES` (const): const DEFAULT_MAX_PAYLOAD_BYTES: 1048576 — Default per-message payload cap (bytes) — `ws` closes the socket with 1009 past this. - `GameWsServer` (type): type GameWsServer = { wss: WebSocketServer; port: () => number; rewind: (args: { serverId: string; atMs: number }) => RewoundPosition[]; close: () => Promise; } — ⚠ undocumented -- `GameWsServerOptions` (type): type GameWsServerOptions = HostRouterOptions & { server?: HttpServer; port?: number; path?: string; } — ⚠ undocumented -- `RewoundPosition` (type): type RewoundPosition = { userId: string; x: number; y: number; z: number; } — A player's interpolated position sampled from history at a past timestamp. +- `GameWsServerOptions` (type): type GameWsServerOptions = HostRouterOptions & { server?: HttpServer; port?: number; path?: string; /** Per-message payload cap in bytes. Defaults to {@link DEFAULT_MAX_PAYLOAD_BYTES}. */ maxPayloadBytes?: number; /** Max concurrent sockets accepted; connections beyond this are closed immediately. D… — ⚠ undocumented - `createGameWsServer` (function): function createGameWsServer(options: GameWsServerOptions): GameWsServer — ⚠ undocumented ## @jgengine/sql @@ -311,17 +315,36 @@ ## @jgengine/ws +- `CommandAuthorize` (type): type CommandAuthorize = (args: { userId: string; op: HostCommandOp; serverId?: string; command?: string; }) => boolean | Promise — Per-command authorization hook: return `false` to reject. Defaults to allow-all when omitted. +- `CommandCatalog` (type): type CommandCatalog = Record — Declared `runCommand` names and their input validators. When set, any `runCommand` name absent from this catalog is rejected as unknown. +- `CommandCatalogEntry` (type): type CommandCatalogEntry = { validate?: (input: unknown) => CommandRejection | null; } — A declared `runCommand` name's input validator, run before the command reaches the game host. +- `CommandGateArgs` (type): type CommandGateArgs = { connection: object; userId: string; op: HostCommandOp; atMs: number; serverId?: string; command?: string; input?: unknown; } — One op attempt to run through the middleware pipeline: which connection, which op, and (for `runCommand`) the command name/input. +- `CommandGateDecision` (type): type CommandGateDecision = { allow: true } | { allow: false; reason: string } — The pipeline's verdict for one {@link CommandGateArgs}: allowed, or rejected with a client-facing reason. +- `CommandLimits` (type): type CommandLimits = Partial> — Per-op rate limits. An op with no entry (or an undefined `limits`) is unlimited. +- `CommandMiddleware` (type): type CommandMiddleware = { check: (args: CommandGateArgs) => Promise; } — A composable rate-limit → validate → authorize pipeline the host router runs before dispatching pose/runCommand/join/browse/voice ops. Every stage defaults to a no-op, so an unconfigured router behaves exactly as before. +- `CommandMiddlewareOptions` (type): type CommandMiddlewareOptions = { limits?: CommandLimits; authorize?: CommandAuthorize; validate?: CommandCatalog; } — Config for {@link createCommandMiddleware}: the same `limits`/`authorize`/`validate` fields accepted by `HostRouterOptions`. +- `CommandRateLimit` (type): type CommandRateLimit = { count: number; perMs: number } — A sliding-window rate limit: at most `count` calls per `perMs` window. +- `CommandRateLimiter` (type): type CommandRateLimiter = { allow: (connection: object, op: HostCommandOp, atMs: number) => boolean; } — A composable per-connection/per-op sliding-window rate limiter. +- `DEFAULT_COMMAND_LIMITS` (const): const DEFAULT_COMMAND_LIMITS: CommandLimits — Recommended per-op limits a host can opt into via `limits: DEFAULT_COMMAND_LIMITS`. Rate limiting is off unless `limits` is set. - `DEFAULT_POSE_RULES` (const): const DEFAULT_POSE_RULES: PoseSyncRules — ⚠ undocumented -- `GameHost` (type): type GameHost = { joinServer: (args: { userId: string; gameId: string; serverId?: string; attributes?: SessionAttributes; }) => Promise; browseServers: (args: { gameId: string; filter?: MatchFilter; limit?: number; }) => Promise; joinByCode: (args: { userId: strin… — A transport-agnostic authoritative game server host that manages sessions, ticking, and persistence. +- `GameHost` (type): type GameHost = { joinServer: (args: { userId: string; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; }) => Promise; browseServers: (args: { gameId: string; filter?: MatchFilter; limit?: number; }) => Promise; joinByCode: (args: … — A transport-agnostic authoritative game server host that manages sessions, ticking, and persistence. - `GameHostOptions` (type): type GameHostOptions = { runtimes?: GameRuntime[]; persistence: HostPersistence; tickMs?: number; slotsPerServer?: number; now?: () => number; createServerId?: () => string; allowedFeedActions?: readonly string[]; } — Configuration for {@link createGameHost}, including persistence, tick rate, and game runtimes. - `HostChangeEvent` (type): type HostChangeEvent = | { type: "server"; serverId: string } | { type: "player"; serverId: string; userId: string } | { type: "feed"; serverId: string; action: string } — A change notification emitted by a `GameHost` for a server, player, or feed mutation. +- `HostCommandOp` (type): type HostCommandOp = "pose" | "runCommand" | "join" | "browse" | "voice" — A host-side op the command middleware pipeline can gate: pose sync, `runCommand`, join/joinByCode, browse, or voice join/leave/publish. - `HostRouter` (type): type HostRouter = { connect: (transport: HostRouterTransport) => HostRouterConnection; rewind: (args: { serverId: string; atMs: number }) => RewoundPosition[]; close: () => void; } — ⚠ undocumented - `HostRouterAuthenticate` (type): type HostRouterAuthenticate = (args: { userId: string; token?: string; }) => Promise | string | null — ⚠ undocumented - `HostRouterConnection` (type): type HostRouterConnection = { handleRaw: (raw: unknown) => void; close: () => void; } — ⚠ undocumented -- `HostRouterOptions` (type): type HostRouterOptions = { host: GameHost; authenticate?: HostRouterAuthenticate; allowAnonymous?: boolean; singleSession?: boolean; poseRules?: PoseSyncRules; positionHistoryMs?: number; chatRateLimit?: ChatRateLimit; chatHistoryLimit?: number; chatMaxBodyLength?: number; now?: () => number; } — ⚠ undocumented +- `HostRouterOptions` (type): type HostRouterOptions = { host: GameHost; authenticate?: HostRouterAuthenticate; allowAnonymous?: boolean; singleSession?: boolean; poseRules?: PoseSyncRules; positionHistoryMs?: number; chatRateLimit?: ChatRateLimit; chatHistoryLimit?: number; chatMaxBodyLength?: number; /** Per-op rate limits for… — ⚠ undocumented - `HostRouterTransport` (type): type HostRouterTransport = { send: (data: string) => void; close: () => void; } — ⚠ undocumented - `HttpReads` (type): type HttpReads = { getTop: (args: { stat: string; scope: LeaderboardScope; serverId?: string; limit?: number; }) => Promise; getLeaderboardProfile: (userId: string) => Promise>; getPlayerProfile: (userId: string) => Promise; li… — ⚠ undocumented - `HttpReadsOptions` (type): type HttpReadsOptions = { baseUrl: string; gameId: string; fetchImpl?: typeof fetch; } — ⚠ undocumented +- `MAX_APPEARANCE_ENTRIES` (const): const MAX_APPEARANCE_ENTRIES: 32 — Max number of keys in a pose `appearance` tag map. +- `MAX_APPEARANCE_VALUE_LENGTH` (const): const MAX_APPEARANCE_VALUE_LENGTH: 256 — Max length of a single `appearance` tag string value, in UTF-16 code units. +- `MAX_COMMAND_LENGTH` (const): const MAX_COMMAND_LENGTH: 4096 — Max length of a `runCommand` command name, in UTF-16 code units. +- `MAX_FEED_ACTION_LENGTH` (const): const MAX_FEED_ACTION_LENGTH: 256 — Max length of a `pushFeed` action name, in UTF-16 code units. +- `MAX_FEED_ENTRY_BYTES` (const): const MAX_FEED_ENTRY_BYTES: 65536 — Max serialized size of a `pushFeed` entry payload, in bytes. +- `MAX_QUEUED_MESSAGES` (const): const MAX_QUEUED_MESSAGES: 64 — Cap on frames queued behind a connection's in-flight message; beyond this a flood gets rejected instead of piling up unbounded promises. +- `OP_LEDGER_LIMIT` (const): const OP_LEDGER_LIMIT: 64 — Max recently-applied `runCommand` op IDs retained per (serverId, userId), oldest evicted first. - `PeerGuest` (type): type PeerGuest = { backend: WsBackend; offer: () => Promise; connect: (answerCode: string) => Promise; close: () => void; } — ⚠ undocumented - `PeerGuestOptions` (type): type PeerGuestOptions = { userId: string; token?: string; rtc?: PeerRtcOptions; } — ⚠ undocumented - `PeerHost` (type): type PeerHost = { backend: WsBackend; host: GameHost; router: HostRouter; accept: (offerCode: string) => Promise; close: () => void; } — ⚠ undocumented @@ -355,7 +378,7 @@ - `WsChannel` (type): type WsChannel = "server" | "player" | "feed" | "presence" | "chat" | "voice" — ⚠ undocumented - `WsChatMessage` (type): type WsChatMessage = { id: string; channelId: string; fromUserId: string; body: string; at: number; } — ⚠ undocumented - `WsChatSync` (type): type WsChatSync = { subscribe: ( serverId: string, channelId: string, onChange: (messages: WsChatMessage[]) => void, ) => () => void; send: (serverId: string, channelId: string, body: string) => Promise; } — ⚠ undocumented -- `WsClientMessage` (type): type WsClientMessage = | { v: 1; t: "hello"; id: number; userId: string; token?: string } | { v: 1; t: "join"; id: number; gameId: string; serverId?: string; attributes?: SessionAttributes } | { v: 1; t: "joinByCode"; id: number; gameId: string; code: string } | { v: 1; t: "browse"; id: number; game… — ⚠ undocumented +- `WsClientMessage` (type): type WsClientMessage = | { v: 1; t: "hello"; id: number; userId: string; token?: string } | { v: 1; t: "join"; id: number; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; } | { v: 1; t: "joinByCode"; id: number; gameId: string; code: string } | { v: 1; t: "browse"; … — ⚠ undocumented - `WsDecodeFailure` (type): type WsDecodeFailure = { reason: string; id?: number; } — ⚠ undocumented - `WsJoinByCodeResult` (type): type WsJoinByCodeResult = JoinServerResult | null — ⚠ undocumented - `WsJoinResult` (type): type WsJoinResult = JoinServerResult — ⚠ undocumented @@ -370,6 +393,8 @@ - `announcePeerHost` (function): function announcePeerHost(host: PeerHost, signaling: PeerSignaling): () => void — ⚠ undocumented - `broadcastChannelSignaling` (function): function broadcastChannelSignaling(room: string): PeerSignaling — ⚠ undocumented - `computeVoiceGain` (function): function computeVoiceGain(def: VoiceChannelDef, distance: number | null): number — ⚠ undocumented +- `createCommandMiddleware` (function): function createCommandMiddleware(options: CommandMiddlewareOptions): CommandMiddleware — Builds the composed command middleware pipeline from game-intent config: `limits`, `validate`, `authorize`. +- `createCommandRateLimiter` (function): function createCommandRateLimiter(limits: CommandLimits): CommandRateLimiter — Creates a sliding-window rate limiter keyed by connection identity and op; ops absent from `limits` are always allowed. - `createGameHost` (function): function createGameHost(options: GameHostOptions): GameHost — Creates a `GameHost` that runs game servers over the given persistence and runtimes. - `createHostRouter` (function): function createHostRouter(options: HostRouterOptions): HostRouter — ⚠ undocumented - `createHttpReads` (function): function createHttpReads(options: HttpReadsOptions): HttpReads — ⚠ undocumented @@ -386,8 +411,27 @@ - `loopbackPipe` (function): function loopbackPipe(router: HostRouter): TransportPipeFactory — ⚠ undocumented - `memoryPersistence` (function): function memoryPersistence(now: () => number = Date.now): HostPersistence — Creates an in-memory `HostPersistence` implementation, useful for tests and ephemeral hosts. - `socketIoPipe` (function): function socketIoPipe(socket: SocketIoLikeSocket): TransportPipeFactory — ⚠ undocumented +- `validateCommandInput` (function): function validateCommandInput(catalog: CommandCatalog | undefined, command: string, input: unknown): CommandRejection | null — Validates a `runCommand` input against a declared catalog. `undefined` catalog means "no declarations" — everything passes through unchanged. - `webSocketPipe` (function): function webSocketPipe(url: string, webSocketFactory: (url: string) => WebSocket = (target) => new WebSocket(target)): TransportPipeFactory — ⚠ undocumented +## @jgengine/ws/commandMiddleware + +- `CommandAuthorize` (type): type CommandAuthorize = (args: { userId: string; op: HostCommandOp; serverId?: string; command?: string; }) => boolean | Promise — Per-command authorization hook: return `false` to reject. Defaults to allow-all when omitted. +- `CommandCatalog` (type): type CommandCatalog = Record — Declared `runCommand` names and their input validators. When set, any `runCommand` name absent from this catalog is rejected as unknown. +- `CommandCatalogEntry` (type): type CommandCatalogEntry = { validate?: (input: unknown) => CommandRejection | null; } — A declared `runCommand` name's input validator, run before the command reaches the game host. +- `CommandGateArgs` (type): type CommandGateArgs = { connection: object; userId: string; op: HostCommandOp; atMs: number; serverId?: string; command?: string; input?: unknown; } — One op attempt to run through the middleware pipeline: which connection, which op, and (for `runCommand`) the command name/input. +- `CommandGateDecision` (type): type CommandGateDecision = { allow: true } | { allow: false; reason: string } — The pipeline's verdict for one {@link CommandGateArgs}: allowed, or rejected with a client-facing reason. +- `CommandLimits` (type): type CommandLimits = Partial> — Per-op rate limits. An op with no entry (or an undefined `limits`) is unlimited. +- `CommandMiddleware` (type): type CommandMiddleware = { check: (args: CommandGateArgs) => Promise; } — A composable rate-limit → validate → authorize pipeline the host router runs before dispatching pose/runCommand/join/browse/voice ops. Every stage defaults to a no-op, so an unconfigured router behaves exactly as before. +- `CommandMiddlewareOptions` (type): type CommandMiddlewareOptions = { limits?: CommandLimits; authorize?: CommandAuthorize; validate?: CommandCatalog; } — Config for {@link createCommandMiddleware}: the same `limits`/`authorize`/`validate` fields accepted by `HostRouterOptions`. +- `CommandRateLimit` (type): type CommandRateLimit = { count: number; perMs: number } — A sliding-window rate limit: at most `count` calls per `perMs` window. +- `CommandRateLimiter` (type): type CommandRateLimiter = { allow: (connection: object, op: HostCommandOp, atMs: number) => boolean; } — A composable per-connection/per-op sliding-window rate limiter. +- `DEFAULT_COMMAND_LIMITS` (const): const DEFAULT_COMMAND_LIMITS: CommandLimits — Recommended per-op limits a host can opt into via `limits: DEFAULT_COMMAND_LIMITS`. Rate limiting is off unless `limits` is set. +- `HostCommandOp` (type): type HostCommandOp = "pose" | "runCommand" | "join" | "browse" | "voice" — A host-side op the command middleware pipeline can gate: pose sync, `runCommand`, join/joinByCode, browse, or voice join/leave/publish. +- `createCommandMiddleware` (function): function createCommandMiddleware(options: CommandMiddlewareOptions): CommandMiddleware — Builds the composed command middleware pipeline from game-intent config: `limits`, `validate`, `authorize`. +- `createCommandRateLimiter` (function): function createCommandRateLimiter(limits: CommandLimits): CommandRateLimiter — Creates a sliding-window rate limiter keyed by connection identity and op; ops absent from `limits` are always allowed. +- `validateCommandInput` (function): function validateCommandInput(catalog: CommandCatalog | undefined, command: string, input: unknown): CommandRejection | null — Validates a `runCommand` input against a declared catalog. `undefined` catalog means "no declarations" — everything passes through unchanged. + ## @jgengine/ws/createWsBackend - `WsBackend` (type): type WsBackend = GameBackend & { pushFeedEntry: (args: { serverId: string; action: string; entry: unknown }) => Promise; browse: (args: { gameId: string; filter?: MatchFilter; limit?: number }) => Promise; joinByCode: (args: { gameId: string; code: string }) => Promise Promise; browseServers: (args: { gameId: string; filter?: MatchFilter; limit?: number; }) => Promise; joinByCode: (args: { userId: strin… — A transport-agnostic authoritative game server host that manages sessions, ticking, and persistence. +- `GameHost` (type): type GameHost = { joinServer: (args: { userId: string; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; }) => Promise; browseServers: (args: { gameId: string; filter?: MatchFilter; limit?: number; }) => Promise; joinByCode: (args: … — A transport-agnostic authoritative game server host that manages sessions, ticking, and persistence. - `GameHostOptions` (type): type GameHostOptions = { runtimes?: GameRuntime[]; persistence: HostPersistence; tickMs?: number; slotsPerServer?: number; now?: () => number; createServerId?: () => string; allowedFeedActions?: readonly string[]; } — Configuration for {@link createGameHost}, including persistence, tick rate, and game runtimes. - `HostChangeEvent` (type): type HostChangeEvent = | { type: "server"; serverId: string } | { type: "player"; serverId: string; userId: string } | { type: "feed"; serverId: string; action: string } — A change notification emitted by a `GameHost` for a server, player, or feed mutation. +- `OP_LEDGER_LIMIT` (const): const OP_LEDGER_LIMIT: 64 — Max recently-applied `runCommand` op IDs retained per (serverId, userId), oldest evicted first. - `createGameHost` (function): function createGameHost(options: GameHostOptions): GameHost — Creates a `GameHost` that runs game servers over the given persistence and runtimes. - `memoryPersistence` (function): function memoryPersistence(now: () => number = Date.now): HostPersistence — Creates an in-memory `HostPersistence` implementation, useful for tests and ephemeral hosts. @@ -411,8 +456,9 @@ - `HostRouter` (type): type HostRouter = { connect: (transport: HostRouterTransport) => HostRouterConnection; rewind: (args: { serverId: string; atMs: number }) => RewoundPosition[]; close: () => void; } — ⚠ undocumented - `HostRouterAuthenticate` (type): type HostRouterAuthenticate = (args: { userId: string; token?: string; }) => Promise | string | null — ⚠ undocumented - `HostRouterConnection` (type): type HostRouterConnection = { handleRaw: (raw: unknown) => void; close: () => void; } — ⚠ undocumented -- `HostRouterOptions` (type): type HostRouterOptions = { host: GameHost; authenticate?: HostRouterAuthenticate; allowAnonymous?: boolean; singleSession?: boolean; poseRules?: PoseSyncRules; positionHistoryMs?: number; chatRateLimit?: ChatRateLimit; chatHistoryLimit?: number; chatMaxBodyLength?: number; now?: () => number; } — ⚠ undocumented +- `HostRouterOptions` (type): type HostRouterOptions = { host: GameHost; authenticate?: HostRouterAuthenticate; allowAnonymous?: boolean; singleSession?: boolean; poseRules?: PoseSyncRules; positionHistoryMs?: number; chatRateLimit?: ChatRateLimit; chatHistoryLimit?: number; chatMaxBodyLength?: number; /** Per-op rate limits for… — ⚠ undocumented - `HostRouterTransport` (type): type HostRouterTransport = { send: (data: string) => void; close: () => void; } — ⚠ undocumented +- `MAX_QUEUED_MESSAGES` (const): const MAX_QUEUED_MESSAGES: 64 — Cap on frames queued behind a connection's in-flight message; beyond this a flood gets rejected instead of piling up unbounded promises. - `RewoundPosition` (type): type RewoundPosition = { userId: string; x: number; y: number; z: number; } — A player's interpolated position sampled from history at a past timestamp. - `createHostRouter` (function): function createHostRouter(options: HostRouterOptions): HostRouter — ⚠ undocumented - `loopbackPipe` (function): function loopbackPipe(router: HostRouter): TransportPipeFactory — ⚠ undocumented @@ -449,12 +495,17 @@ ## @jgengine/ws/protocol +- `MAX_APPEARANCE_ENTRIES` (const): const MAX_APPEARANCE_ENTRIES: 32 — Max number of keys in a pose `appearance` tag map. +- `MAX_APPEARANCE_VALUE_LENGTH` (const): const MAX_APPEARANCE_VALUE_LENGTH: 256 — Max length of a single `appearance` tag string value, in UTF-16 code units. +- `MAX_COMMAND_LENGTH` (const): const MAX_COMMAND_LENGTH: 4096 — Max length of a `runCommand` command name, in UTF-16 code units. +- `MAX_FEED_ACTION_LENGTH` (const): const MAX_FEED_ACTION_LENGTH: 256 — Max length of a `pushFeed` action name, in UTF-16 code units. +- `MAX_FEED_ENTRY_BYTES` (const): const MAX_FEED_ENTRY_BYTES: 65536 — Max serialized size of a `pushFeed` entry payload, in bytes. - `WS_PROTOCOL_VERSION` (const): const WS_PROTOCOL_VERSION: 1 — ⚠ undocumented - `WsAppearance` (type): type WsAppearance = Record — Client-set cosmetic/state tags carried alongside a pose (skin, mount, emote, ...). Primitive values only. - `WsBrowseResult` (type): type WsBrowseResult = SessionListing[] — ⚠ undocumented - `WsChannel` (type): type WsChannel = "server" | "player" | "feed" | "presence" | "chat" | "voice" — ⚠ undocumented - `WsChatMessage` (type): type WsChatMessage = { id: string; channelId: string; fromUserId: string; body: string; at: number; } — ⚠ undocumented -- `WsClientMessage` (type): type WsClientMessage = | { v: 1; t: "hello"; id: number; userId: string; token?: string } | { v: 1; t: "join"; id: number; gameId: string; serverId?: string; attributes?: SessionAttributes } | { v: 1; t: "joinByCode"; id: number; gameId: string; code: string } | { v: 1; t: "browse"; id: number; game… — ⚠ undocumented +- `WsClientMessage` (type): type WsClientMessage = | { v: 1; t: "hello"; id: number; userId: string; token?: string } | { v: 1; t: "join"; id: number; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; } | { v: 1; t: "joinByCode"; id: number; gameId: string; code: string } | { v: 1; t: "browse"; … — ⚠ undocumented - `WsDecodeFailure` (type): type WsDecodeFailure = { reason: string; id?: number; } — ⚠ undocumented - `WsJoinByCodeResult` (type): type WsJoinByCodeResult = JoinServerResult | null — ⚠ undocumented - `WsJoinResult` (type): type WsJoinResult = JoinServerResult — ⚠ undocumented diff --git a/.claude/skills/jgengine-multiplayer/reference.md b/.claude/skills/jgengine-multiplayer/reference.md index 4dc463c21..f66423367 100644 --- a/.claude/skills/jgengine-multiplayer/reference.md +++ b/.claude/skills/jgengine-multiplayer/reference.md @@ -1,4 +1,4 @@ -# jgengine domain API — Multiplayer and the backend seam +# jgengine domain API — Multiplayer and the backend seam Reference module for the [`jgengine-multiplayer` API](SKILL.md) skill. Load this when you need the transport/host/persistence seam. @@ -17,53 +17,53 @@ type GameBackend = { type LiveGameBackend = GameBackend & { presenceSync: PresenceSync; // subscribe(serverId, onChange) + syncPose(serverId, pose) pushFeedEntry: (args: { serverId: string; action: string; entry: unknown }) => Promise; - chatSyncFor?: (serverId: string) => ChatSync; // present ⇒ the shell also bridges global chat + chatSyncFor?: (serverId: string) => ChatSync; // present ⇒ the shell also bridges global chat }; type MultiplayerSession = { gameId: string; userId: string; backend: LiveGameBackend; feedActions: string[] }; ``` -`GameRuntimeFeeds` is a callback contract (`subscribe*(args, onChange) => FeedUnsubscribe`) — backend-neutral, no reactive-query shapes. Swapping backends = implement `GameBackend` (or the richer `LiveGameBackend` a shell session needs) + host authoritative `runCommand` elsewhere; game `commands` and `loop` do not change. Adapter configs in defineGame: `offline()`, `convexPresence({ topology })` / `convex({ topology, authority: "server" })`, `wsPresence({ topology, url? })` / `ws({ topology, url?, authority: "server" })`, `fly({ app, topology?, path? })` (ws sugar → url `wss://.fly.dev`), `socketIo({ topology?, url? })`, `p2p({ topology?, room? })` (topology defaults `"private"`), `lan({ topology?, port?, path? })`, `servers({ maxServers, slotsPerServer, minPlayersToStart, adapter })`. `topology` is exactly `"shared" | "lobbies" | "private"` — no other values exist; a persistent MMO world is `server: "persistent"` + topology `"shared"`. +`GameRuntimeFeeds` is a callback contract (`subscribe*(args, onChange) => FeedUnsubscribe`) — backend-neutral, no reactive-query shapes. Swapping backends = implement `GameBackend` (or the richer `LiveGameBackend` a shell session needs) + host authoritative `runCommand` elsewhere; game `commands` and `loop` do not change. Adapter configs in defineGame: `offline()`, `convexPresence({ topology })` / `convex({ topology, authority: "server" })`, `wsPresence({ topology, url? })` / `ws({ topology, url?, authority: "server" })`, `fly({ app, topology?, path? })` (ws sugar → url `wss://.fly.dev`), `socketIo({ topology?, url? })`, `p2p({ topology?, room? })` (topology defaults `"private"`), `lan({ topology?, port?, path? })`, `servers({ maxServers, slotsPerServer, minPlayersToStart, adapter })`. `topology` is exactly `"shared" | "lobbies" | "private"` — no other values exist; a persistent MMO world is `server: "persistent"` + topology `"shared"`. -**Resolvers turn a game + adapter config into a `MultiplayerSession`, or `null` when the config doesn't match** — offline stays the default whenever none resolves: `resolveShellMultiplayer({ game, gameId, url?, userId?, force?, feedActions? })` (`@jgengine/shell/multiplayer`) resolves `ws` (url from arg ?? adapter ?? `ws://localhost:8080/ws`) and `lan` (url derived from `window.location`); `resolvePeerShellMultiplayer({ gameId, role, room? })` is the `p2p` counterpart over `broadcastChannelSignaling`; `resolveConvexMultiplayer({ game, gameId, url?, client?, api?, userId?, force?, feedActions?, poseTuning? })` (`@jgengine/convex/resolveConvexMultiplayer`) resolves on `"convex"`, wrapping `createConvexBackend`. `adapterOf` / `multiplayerAdapterKind` (`@jgengine/core/runtime/adapter`) are the shared classifiers every resolver calls. A host that supports several transports tries them in sequence and hands whichever resolves (or `null`) to `` — see `apps/dev/src/main.tsx` for `resolveConvexMultiplayer(...) ?? resolveShellMultiplayer(...)`. +**Resolvers turn a game + adapter config into a `MultiplayerSession`, or `null` when the config doesn't match** — offline stays the default whenever none resolves: `resolveShellMultiplayer({ game, gameId, url?, userId?, force?, feedActions? })` (`@jgengine/shell/multiplayer`) resolves `ws` (url from arg ?? adapter ?? `ws://localhost:8080/ws`) and `lan` (url derived from `window.location`); `resolvePeerShellMultiplayer({ gameId, role, room? })` is the `p2p` counterpart over `broadcastChannelSignaling`; `resolveConvexMultiplayer({ game, gameId, url?, client?, api?, userId?, force?, feedActions?, poseTuning? })` (`@jgengine/convex/resolveConvexMultiplayer`) resolves on `"convex"`, wrapping `createConvexBackend`. `adapterOf` / `multiplayerAdapterKind` (`@jgengine/core/runtime/adapter`) are the shared classifiers every resolver calls. A host that supports several transports tries them in sequence and hands whichever resolves (or `null`) to `` — see `apps/dev/src/main.tsx` for `resolveConvexMultiplayer(...) ?? resolveShellMultiplayer(...)`. -Once a session resolves, `GamePlayerShell` wires it up with **no game code changes**: pose presence (subscribes `presenceSync`, renders every other member as `RemotePlayers`), `feedActions` (default `entity.died`) bridged both ways through `pushFeedEntry` / `feeds.subscribeFeed` with echo suppression, and — only when the backend exposes `chatSyncFor` — `global`-kind chat channels relayed through it (`whisper` / `party` / `proximity` stay local regardless of backend). Everything else in `GameContext` (inventories, quests, world state) stays client-local unless the game also registers a server-side `GameRuntime`. +Once a session resolves, `GamePlayerShell` wires it up with **no game code changes**: pose presence (subscribes `presenceSync`, renders every other member as `RemotePlayers`), `feedActions` (default `entity.died`) bridged both ways through `pushFeedEntry` / `feeds.subscribeFeed` with echo suppression, and — only when the backend exposes `chatSyncFor` — `global`-kind chat channels relayed through it (`whisper` / `party` / `proximity` stay local regardless of backend). Everything else in `GameContext` (inventories, quests, world state) stays client-local unless the game also registers a server-side `GameRuntime`. -A synced pose (`@jgengine/ws/protocol`'s `WsPose`/`WsPresenceRow`) carries an optional `appearance: Record` alongside position/rotation — primitive-valued client-set tags (skin id, mount id, active emote, team color) riding the same pose-sync frames as position, so a remote player's cosmetic state updates on the existing presence channel instead of a second round-trip. +A synced pose (`@jgengine/ws/protocol`'s `WsPose`/`WsPresenceRow`) carries an optional `appearance: Record` alongside position/rotation — primitive-valued client-set tags (skin id, mount id, active emote, team color) riding the same pose-sync frames as position, so a remote player's cosmetic state updates on the existing presence channel instead of a second round-trip. -**Server side** — `@jgengine/convex/server` ships an entire authoritative Convex backend as factories, not a template to copy: `jgengineTables()` (schema spread), `createGameServerFunctions({ runtimes?, auth? })`, `createLeaderboardFunctions({ auth? })`, `createPresenceFunctions({ auth?, freshWindowMs? })`, `createChatFunctions({ auth?, historyLimit?, maxBodyLength?, minIntervalMs? })`, and `jgengineCronSpecs()` (tick/flush interval metadata for a `crons.ts`). A consumer's `convex/` directory is ~25 lines: `schema.ts` (`defineSchema({ ...jgengineTables() })`), four one-line re-export files (`runtime.ts`, `leaderboard.ts`, `presence.ts`, `chat.ts`, each just calling its factory), and `crons.ts` registering the tick (1s) + flush (60s) internal mutations — see `examples/convex-host` for the reference shape. No game-specific code lives there; any JGengine game can point at the same deployment. Games without a registered `GameRuntime` fall back to a no-save runtime that only understands `engine.ping`; pass `createGameServerFunctions({ runtimes: [createGameRuntime({ gameId, commands, loop, save })] })` to make `runCommand` / tick / save actually do something. +**Server side** — `@jgengine/convex/server` ships an entire authoritative Convex backend as factories, not a template to copy: `jgengineTables()` (schema spread), `createGameServerFunctions({ runtimes?, auth? })`, `createLeaderboardFunctions({ auth? })`, `createPresenceFunctions({ auth?, freshWindowMs? })`, `createChatFunctions({ auth?, historyLimit?, maxBodyLength?, minIntervalMs? })`, and `jgengineCronSpecs()` (tick/flush interval metadata for a `crons.ts`). A consumer's `convex/` directory is ~25 lines: `schema.ts` (`defineSchema({ ...jgengineTables() })`), four one-line re-export files (`runtime.ts`, `leaderboard.ts`, `presence.ts`, `chat.ts`, each just calling its factory), and `crons.ts` registering the tick (1s) + flush (60s) internal mutations — see `examples/convex-host` for the reference shape. No game-specific code lives there; any JGengine game can point at the same deployment. Games without a registered `GameRuntime` fall back to a no-save runtime that only understands `engine.ping`; pass `createGameServerFunctions({ runtimes: [createGameRuntime({ gameId, commands, loop, save })] })` to make `runCommand` / tick / save actually do something. -Auth defaults to `"anonymous"` (`JgAuthMode`) on every factory — the client's `externalId` is trusted as claimed, fine for local dev but spoofable. Pass `{ auth: "required" }` to every factory for production; the resolved actor becomes `ctx.auth.getUserIdentity()`'s `subject` and `externalId` is only cross-checked against it, never trusted alone. +Auth defaults to `"anonymous"` (`JgAuthMode`) on every factory — the client's `externalId` is trusted as claimed, fine for local dev but spoofable. Pass `{ auth: "required" }` to every factory for production; the resolved actor becomes `ctx.auth.getUserIdentity()`'s `subject` and `externalId` is only cross-checked against it, never trusted alone. -**Flip a game online, step by step:** (1) add `multiplayer: convexPresence({ topology: "shared" })` to `game.config.ts` (any game with that adapter) — or `convex({ topology: "shared", authority: "server" })` for a genuinely shared sim, see `examples/HOSTED.md`; (2) stand up a Convex deployment — `bunx convex dev` inside `examples/convex-host` codegens `convex/_generated/` and prints the dev URL; (3) run the client with `VITE_CONVEX_URL=` (env or `.env.local`) — `apps/dev/src/main.tsx` reads it and forces `resolveConvexMultiplayer`. No Convex Cloud account is required: `examples/convex-host/docker-compose.yml` runs the open-source backend (FSL-1.1-Apache-2.0) locally or on any Docker host (Fly.io/Railway templates upstream; Vercel can host only the game client, never the backend) — set `CONVEX_SELF_HOSTED_URL` + `CONVEX_SELF_HOSTED_ADMIN_KEY` in `.env.local` and the same `bunx convex dev` deploys there with full parity (crons, scheduling, file storage). The ws path is the same shape: `multiplayer: wsPresence({ topology })`, a `@jgengine/node` host (`createGameHost` + `createGameWsServer`), and `VITE_JG_WS_URL` forcing `resolveShellMultiplayer`. +**Flip a game online, step by step:** (1) add `multiplayer: convexPresence({ topology: "shared" })` to `game.config.ts` (any game with that adapter) — or `convex({ topology: "shared", authority: "server" })` for a genuinely shared sim, see `examples/HOSTED.md`; (2) stand up a Convex deployment — `bunx convex dev` inside `examples/convex-host` codegens `convex/_generated/` and prints the dev URL; (3) run the client with `VITE_CONVEX_URL=` (env or `.env.local`) — `apps/dev/src/main.tsx` reads it and forces `resolveConvexMultiplayer`. No Convex Cloud account is required: `examples/convex-host/docker-compose.yml` runs the open-source backend (FSL-1.1-Apache-2.0) locally or on any Docker host (Fly.io/Railway templates upstream; Vercel can host only the game client, never the backend) — set `CONVEX_SELF_HOSTED_URL` + `CONVEX_SELF_HOSTED_ADMIN_KEY` in `.env.local` and the same `bunx convex dev` deploys there with full parity (crons, scheduling, file storage). The ws path is the same shape: `multiplayer: wsPresence({ topology })`, a `@jgengine/node` host (`createGameHost` + `createGameWsServer`), and `VITE_JG_WS_URL` forcing `resolveShellMultiplayer`. -**Game code never calls backend functions for gameplay verbs.** The generic server surface (no game nouns): `joinServer / leaveServer / runCommand / getServer / getPlayerProfile / getFeed / listOpenServers`, leaderboard `getTop / getProfile` (writes are internal — increments stage under `LEADERBOARD_PENDING_KEY` in server session and drain through the persistence seam on flush). +**Game code never calls backend functions for gameplay verbs.** The generic server surface (no game nouns): `joinServer / leaveServer / runCommand / getServer / getPlayerProfile / getFeed / listOpenServers`, leaderboard `getTop / getProfile` (writes are internal — increments stage under `LEADERBOARD_PENDING_KEY` in server session and drain through the persistence seam on flush). -Persistence tiers (`@jgengine/core/runtime/hostPersistence` — `HostPersistence` interface, `GameServerRecord` / `PlayerProfileRecord` / `WorldChunkRecord`, `planServerPersist` / `buildHydratePlayers` / `shouldAutoSave` / `trimFeedEntries`): server session, player profile (split on join — `isNew` = no profile), world chunks, leaderboards, feeds (ring of 20). Saves store ids/counts/positions; catalogs stay live so balance patches apply retroactively. Register runnable games host-side via `createGameRuntime({ gameId, commands, loop, save })` (`@jgengine/core/runtime/gameRuntime` — host plumbing, not game authoring) — those server hooks are `ServerLoopHooks` (snapshot-based), distinct from the client `GameLoop`. +Persistence tiers (`@jgengine/core/runtime/hostPersistence` — `HostPersistence` interface, `GameServerRecord` / `PlayerProfileRecord` / `WorldChunkRecord`, `planServerPersist` / `buildHydratePlayers` / `shouldAutoSave` / `trimFeedEntries`): server session, player profile (split on join — `isNew` = no profile), world chunks, leaderboards, feeds (ring of 20). Saves store ids/counts/positions; catalogs stay live so balance patches apply retroactively. Register runnable games host-side via `createGameRuntime({ gameId, commands, loop, save })` (`@jgengine/core/runtime/gameRuntime` — host plumbing, not game authoring) — those server hooks are `ServerLoopHooks` (snapshot-based), distinct from the client `GameLoop`. -**Netcode-depth primitives (pure core, backend-neutral).** These sit *above* the transport — game code drives them inside `commands`/`loop`; the host retains what must be authoritative: -- **Lag-compensated hit reg** (`multiplayer/lagCompensation`, #104). `createPositionHistory({ historyMs })` is an N-sample ring per entity; the authoritative `@jgengine/node` ws host records every accepted presence pose and exposes `server.rewind({ serverId, atMs })`. A hitscan command rewinds to `rewindTimestamp(now, rtt, interpDelay)` (= `now − rtt/2 − interpDelay`) and calls `resolveHitscan(history, targets, ray, atMs)` — coarse server-side rewind, **not** full rollback. Valorant/Apex twitch hit reg. +**Netcode-depth primitives (pure core, backend-neutral).** These sit *above* the transport — game code drives them inside `commands`/`loop`; the host retains what must be authoritative: +- **Lag-compensated hit reg** (`multiplayer/lagCompensation`, #104). `createPositionHistory({ historyMs })` is an N-sample ring per entity; the authoritative `@jgengine/node` ws host records every accepted presence pose and exposes `server.rewind({ serverId, atMs })`. A hitscan command rewinds to `rewindTimestamp(now, rtt, interpDelay)` (= `now − rtt/2 − interpDelay`) and calls `resolveHitscan(history, targets, ray, atMs)` — coarse server-side rewind, **not** full rollback. Valorant/Apex twitch hit reg. - **Simultaneous hidden-commit + reveal** (`multiplayer/simultaneousCommit`, #105). `createCommitRound({ participants })`: each side `seal`s a sealed action; nothing is readable until `allSealed()`, then `reveal()` returns commits in **participant order** (deterministic regardless of arrival), which `resolveCommits` folds. Marvel Snap face-down-then-reveal. -- **Combat-snapshot replay** (`multiplayer/combatSnapshot`, #106). `serializeBoard({ ownerId, units, stats, seed })` deep-freezes a build into a portable `BoardSnapshot`; `replayCombat(a, b, rules)` resolves it **deterministically** (seeded PRNG) against a live opponent's snapshot — distinct from live-sync adapters. The Bazaar async PvP. -- **Auth identity seam** (`multiplayer/identity`). `AuthSession { userId, displayName?, avatarUrl?, email?, isNew? }` is the one shape every social/multiplayer system keys off. `sessionPlayer(session)` maps it onto the `player: { userId, isNew }` argument of `createGameContext`; `resolveGuestSession(seed?)` mints a stable anonymous id for local/dev. Clerk and better-auth wire in through the `@jgengine/react/identity` adapters (`clerkIdentity(useUser())`, `betterAuthIdentity(authClient.useSession())`) — structural mappers over the shapes those hooks return, so neither SDK is a dependency of any engine package. +- **Combat-snapshot replay** (`multiplayer/combatSnapshot`, #106). `serializeBoard({ ownerId, units, stats, seed })` deep-freezes a build into a portable `BoardSnapshot`; `replayCombat(a, b, rules)` resolves it **deterministically** (seeded PRNG) against a live opponent's snapshot — distinct from live-sync adapters. The Bazaar async PvP. +- **Auth identity seam** (`multiplayer/identity`). `AuthSession { userId, displayName?, avatarUrl?, email?, isNew? }` is the one shape every social/multiplayer system keys off. `sessionPlayer(session)` maps it onto the `player: { userId, isNew }` argument of `createGameContext`; `resolveGuestSession(seed?)` mints a stable anonymous id for local/dev. Clerk and better-auth wire in through the `@jgengine/react/identity` adapters (`clerkIdentity(useUser())`, `betterAuthIdentity(authClient.useSession())`) — structural mappers over the shapes those hooks return, so neither SDK is a dependency of any engine package. - **Session matchmaking** (`multiplayer/matchmaking`, #109). Filters are DATA: `browseSessions(listings, filter, { limit })` hides private/closed, `findByJoinCode` (loose-normalized codes), `quickMatch` fills the fullest joinable lobby. The node host carries generic `SessionAttributes` (`label`/`mode`/`visibility`/`joinCode`/`tags`) on `GameServerRecord`/`ServerListing` and adds `browseServers` + `joinByCode`; the ws backend exposes `browse` / `joinByCode` / `createSession`. Fortnite island browse, Web Fishing join-by-code. -**Transport pipe seam** (`@jgengine/ws/pipe`) — `createWsBackend` and the host-side `createHostRouter` don't require a raw WebSocket; both run over any bidirectional string channel. `TransportPipe { send(data: string), close() }` + `TransportPipeHandlers { onOpen, onMessage(data), onClose }`; a `TransportPipeFactory = (handlers) => TransportPipe` opens one connection. `webSocketPipe(url, webSocketFactory?)` is the browser-`WebSocket` default. `createWsBackend({ userId, url?, pipe? })` takes either (one of the two is required) — this seam is what lets the same JSON wire protocol ride a raw ws socket, socket.io, an in-process loopback, or a WebRTC data channel with one implementation. +**Transport pipe seam** (`@jgengine/ws/pipe`) — `createWsBackend` and the host-side `createHostRouter` don't require a raw WebSocket; both run over any bidirectional string channel. `TransportPipe { send(data: string), close() }` + `TransportPipeHandlers { onOpen, onMessage(data), onClose }`; a `TransportPipeFactory = (handlers) => TransportPipe` opens one connection. `webSocketPipe(url, webSocketFactory?)` is the browser-`WebSocket` default. `createWsBackend({ userId, url?, pipe? })` takes either (one of the two is required) — this seam is what lets the same JSON wire protocol ride a raw ws socket, socket.io, an in-process loopback, or a WebRTC data channel with one implementation. -**Browser-safe host + router** (`@jgengine/ws/host`, `@jgengine/ws/hostRouter`) — `createGameHost({ runtimes, persistence, tickMs? })` and `memoryPersistence()` moved here from `@jgengine/node` (which still re-exports both, unchanged, from `@jgengine/node/host` / `@jgengine/node/persistence` — not a breaking change); the host itself has zero Node dependencies, so a browser tab can host a session. `createHostRouter({ host, authenticate?, poseRules?, positionHistoryMs?, chatRateLimit?, chatHistoryLimit?, chatMaxBodyLength?, now? }): HostRouter` is the ws wire-protocol session logic extracted out of `createGameWsServer` — `.connect(transport: { send, close }) → { handleRaw, close }` binds one connection, `.rewind` replays lag-comp history, `.close` tears the router down. `loopbackPipe(router): TransportPipeFactory` wires a `createWsBackend` straight into an in-process router — how a host player plays over their own hosted session with no socket at all. +**Browser-safe host + router** (`@jgengine/ws/host`, `@jgengine/ws/hostRouter`) — `createGameHost({ runtimes, persistence, tickMs? })` and `memoryPersistence()` moved here from `@jgengine/node` (which still re-exports both, unchanged, from `@jgengine/node/host` / `@jgengine/node/persistence` — not a breaking change); the host itself has zero Node dependencies, so a browser tab can host a session. `createHostRouter({ host, authenticate?, poseRules?, positionHistoryMs?, chatRateLimit?, chatHistoryLimit?, chatMaxBodyLength?, now? }): HostRouter` is the ws wire-protocol session logic extracted out of `createGameWsServer` — `.connect(transport: { send, close }) → { handleRaw, close }` binds one connection, `.rewind` replays lag-comp history, `.close` tears the router down. `loopbackPipe(router): TransportPipeFactory` wires a `createWsBackend` straight into an in-process router — how a host player plays over their own hosted session with no socket at all. -**Socket.IO transport** (`@jgengine/ws/socketIoPipe`, `@jgengine/node/socketIoServer`) — `SocketIoLikeSocket` is a structural client-socket shape (`connected`/`on`/`off`/`send`/`disconnect`; no socket.io dependency). `socketIoPipe(socket): TransportPipeFactory` + `createSocketIoBackend({ socket, userId, … }): WsBackend` ride the existing wire protocol over socket.io's `send`/`message` frames. Server side, `@jgengine/node`'s `attachGameSocketIoServer({ io, host, …router options }): { rewind, close }` binds a structural `SocketIoLikeServer`/`SocketIoLikeServerSocket` to the router — no socket.io dependency in the type, just the shape. +**Socket.IO transport** (`@jgengine/ws/socketIoPipe`, `@jgengine/node/socketIoServer`) — `SocketIoLikeSocket` is a structural client-socket shape (`connected`/`on`/`off`/`send`/`disconnect`; no socket.io dependency). `socketIoPipe(socket): TransportPipeFactory` + `createSocketIoBackend({ socket, userId, … }): WsBackend` ride the existing wire protocol over socket.io's `send`/`message` frames. Server side, `@jgengine/node`'s `attachGameSocketIoServer({ io, host, …router options }): { rewind, close }` binds a structural `SocketIoLikeServer`/`SocketIoLikeServerSocket` to the router — no socket.io dependency in the type, just the shape. -**WebRTC P2P** (`@jgengine/ws/peer`) — one browser tab is the authoritative host; no server process. `encodePeerSignal`/`decodePeerSignal` turn an SDP offer/answer into a copy-pasteable base64url code. `createPeerHost({ userId, host?, runtimes?, persistence?, tickMs?, router?, rtc? }): PeerHost` (persistence defaults to `memoryPersistence()`) runs a `GameHost` + `HostRouter` in the host tab, exposing `backend` (the host player's own loopback `WsBackend`) and `accept(offerCode) → Promise` per joining guest. `createPeerGuest({ userId, token?, rtc? }): PeerGuest` exposes `backend`, `offer()`, `connect(answerCode)`; both close via `.close()`. Signaling is a swappable seam — `PeerSignaling { publishOffer, onOffer, close }` — with `broadcastChannelSignaling(room)` covering same-origin multi-tab automatically; `announcePeerHost(host, signaling)` / `joinPeerSession(guest, signaling) → Promise` wire a host/guest to a signaling channel in one call. Cross-machine play is manual copy/paste of the offer/answer codes; there is no auto-reconnect. +**WebRTC P2P** (`@jgengine/ws/peer`) — one browser tab is the authoritative host; no server process. `encodePeerSignal`/`decodePeerSignal` turn an SDP offer/answer into a copy-pasteable base64url code. `createPeerHost({ userId, host?, runtimes?, persistence?, tickMs?, router?, rtc? }): PeerHost` (persistence defaults to `memoryPersistence()`) runs a `GameHost` + `HostRouter` in the host tab, exposing `backend` (the host player's own loopback `WsBackend`) and `accept(offerCode) → Promise` per joining guest. `createPeerGuest({ userId, token?, rtc? }): PeerGuest` exposes `backend`, `offer()`, `connect(answerCode)`; both close via `.close()`. Signaling is a swappable seam — `PeerSignaling { publishOffer, onOffer, close }` — with `broadcastChannelSignaling(room)` covering same-origin multi-tab automatically; `announcePeerHost(host, signaling)` / `joinPeerSession(guest, signaling) → Promise` wire a host/guest to a signaling channel in one call. Cross-machine play is manual copy/paste of the offer/answer codes; there is no auto-reconnect. Backends: -- **Convex** — `@jgengine/convex` `createConvexBackend({ client, gameId, userId, api?, poseTuning?, presence? })` (a `LiveGameBackend`, `api` defaults to `anyApi`); server side is `@jgengine/convex/server`'s factories (tables `jgGameServers`, `jgPlayerProfiles`, `jgWorldChunks`, `jgLeaderboardRows`, `jgFeedBuffers`, `jgPoses`, `jgChatMessages`); a 1s tick cron runs loop ticks + auto-save, a 60s cron flushes dirty servers. -- **Node host** — `createGameHost({ runtimes, persistence, tickMs? })` now lives in `@jgengine/ws/host` (browser-safe); `@jgengine/node` re-exports it unchanged from `@jgengine/node/host` (same for `memoryPersistence` from `@jgengine/node/persistence`) — runs the authoritative loop in any JS process (in-memory snapshots, save-cadence flush), plus `browseServers({ gameId, filter? })` / `joinByCode({ userId, gameId, code })` and `joinServer({ …, attributes })` for coded/private lobbies. `memoryPersistence()` / `filePersistence(dir)` (Node-only, still `@jgengine/node`) implement `HostPersistence`. `createGameWsServer({ host, port | server, authenticate?, poseRules?, positionHistoryMs? })` is now a thin `@jgengine/node` binding of `@jgengine/ws/hostRouter`'s `createHostRouter` onto the `ws` npm package (same public API + `RewoundPosition` re-export, versioned JSON protocol in `@jgengine/ws/protocol`, poses clamped server-side via `decidePoseSync`) and retains presence history for `rewind`. `toNodeHandler(webHandler)` (`@jgengine/node/webHandler`) bridges a fetch-standard `(Request) => Promise` handler onto `(IncomingMessage, ServerResponse)` for express/node servers — e.g. mounting `createReadsHandler(...)` on an express route. -- **WebSocket client** — `@jgengine/ws` `createWsBackend({ userId, url?, pipe? })` returns a `GameBackend` (plus `pushFeedEntry`, `browse` / `joinByCode` / `createSession`, `presenceSync` with client-side `poseSyncGate`); one of `url`/`pipe` is required — `url` opens the default `webSocketPipe`, `pipe` accepts any `TransportPipeFactory` (socket.io, WebRTC, loopback, custom). Browser-safe, imports core only. `createHttpReads({ baseUrl, gameId })` gives plain-fetch reads (`getTop / getLeaderboardProfile / getPlayerProfile / listOpenServers`) — no live-query dependency. `createReadsHandler({ persistence, basePath?, listOpenServers? })` (`@jgengine/ws/readsHandler`) is the server side of the same contract: a fetch-standard `(Request) => Promise` serving those four read routes, mountable directly in any framework's route handler (Next catch-all, TanStack Start server route); `persistence` accepts a lazy factory memoized on first request; `listOpenServers` lets a live `createGameHost` serve fresher listings than persistence has flushed. -- **Postgres** — `@jgengine/sql` `ensureSchema(pool)` + `sqlPersistence(pool)` implement `HostPersistence` over any pg-compatible pool (structural interface, no hard `pg` dep; tables `jg_game_servers`, `jg_player_profiles`, `jg_world_chunks`, `jg_leaderboard_rows`, `jg_feed_entries`). `HostPersistence.savePlan` applies a whole `ServerPersistPlan` in one transaction (leaderboard drain included); hosts fall back to per-tier calls when absent. -- **Clients** — `@jgengine/shell` (`GamePlayerShell`; each client supplies its own `GameRegistry`) is the shared player: it works in Vite, Next.js, or a Tauri webview; the authoritative ws host stays a standalone process (or, over `@jgengine/ws/peer`, the host player's own browser tab). -- **Shell multiplayer** — every resolver produces a `MultiplayerSession` (`ShellMultiplayer` is an alias of it). `resolveShellMultiplayer` resolves straight off `defineGame`'s `multiplayer` adapter config: `ws(...)` connects to `url ?? adapter.url ?? ws://localhost:8080/ws`; `lan(...)` derives `ws(s)://:` from `window.location`, so any browser on the LAN auto-connects to whichever machine served the page; other adapter kinds resolve to `null` (or fall back to the plain ws URL under `force` / the web dev route's `?ws` / desktop's `VITE_JG_WS_URL`). `resolvePeerShellMultiplayer({ gameId, role: "host" | "join", room?, userId?, feedActions? }): Promise` is the `p2p` counterpart — hosts or joins over `broadcastChannelSignaling`, no ws URL involved; `apps/dev` wires it behind `?p2p=host` / `?p2p=join`. `resolveConvexMultiplayer` is the `convex` counterpart (forced by `VITE_CONVEX_URL` the same way). `` then joins a server, pose-syncs the local player, renders remote players from the presence roster, bridges feed actions (default `entity.died`) both ways with echo suppression, and — when the backend exposes `chatSyncFor` — relays `global`-kind chat channels too. Game code unchanged either way. -- **Appearance replication.** Presence rows (`WsPresenceRow`) carry an optional per-slot `appearance?: WsAppearance` (`Record`) channel alongside pose — cosmetic ids, `"#rrggbb"` tints, model keys; slot semantics are game-defined, but the convention is slot `"tint"` = a hex tint the shell's `RemotePlayers` applies to recolor the remote capsule in place of the default hash color. Appearance rides the existing pose message — no protocol bump. The client pose-sync gate force-sends when appearance differs (shallow compare) even with zero movement, still rate-limited by `minIntervalMs`/heartbeat; the server accepts appearance-only changes and echoes the last-known appearance on every presence row. Wire `ctx.player.cosmetics.get(userId)` into the outgoing pose's `appearance` to replicate a player's equipped cosmetics for free. -- **Voice channels** — `@jgengine/ws/voiceChannel` (`createVoiceChannelRouter(channels?)`) is a thin, coarse layer on top of the same transport/presence model: it ships the channel/falloff **routing model**, not a WebRTC media stack (no audio transport — the media plane stays behind `multiplayer/voiceContract`'s `VoiceTransport` signaling seam: `join / leave / publish(streamId) / subscribers`; `createWsBackend(...).voiceSync` / `.voiceTransportFor(serverId)` implement it over `voiceJoin`/`voiceLeave`/`voicePublish` frames with host-relayed channel rosters, and `createLocalVoiceTransport()` covers local/dev; peers exchange stream **descriptors** here and negotiate actual media host-side). Mic capture + push-to-talk live in `@jgengine/react/voice` (`useVoice`, `createPushToTalk` state model: hold / toggle / openMic, mute-gated). `VoiceChannelDef = { id, positional, falloff?, gain? }` — `positional: true` channels (proximity voice) attenuate by distance using the same `@jgengine/core/audio/audioFalloff` curve as positional SFX; `positional: false` channels (walkie/crew) play at flat gain regardless of distance. A member `join`s any number of channels at once (a Sea of Thieves–style crew channel *and* nearby-ship proximity, simultaneously); `updatePosition(userId, xyz)` feeds positions (typically mirrored from `WsPresenceRow`); `setMuted(userId, bool)` silences every channel from that speaker at once. `resolveRoutes(listenerUserId)` returns one `{ fromUserId, channelId, gain }` per shared channel — the mixer plays each route independently, so the same speaker can be loud on `walkie` and near-silent on `proximity` at the same time. +- **Convex** — `@jgengine/convex` `createConvexBackend({ client, gameId, userId, api?, poseTuning?, presence? })` (a `LiveGameBackend`, `api` defaults to `anyApi`); server side is `@jgengine/convex/server`'s factories (tables `jgGameServers`, `jgPlayerProfiles`, `jgWorldChunks`, `jgLeaderboardRows`, `jgFeedBuffers`, `jgPoses`, `jgChatMessages`); a 1s tick cron runs loop ticks + auto-save, a 60s cron flushes dirty servers. +- **Node host** — `createGameHost({ runtimes, persistence, tickMs? })` now lives in `@jgengine/ws/host` (browser-safe); `@jgengine/node` re-exports it unchanged from `@jgengine/node/host` (same for `memoryPersistence` from `@jgengine/node/persistence`) — runs the authoritative loop in any JS process (in-memory snapshots, save-cadence flush), plus `browseServers({ gameId, filter? })` / `joinByCode({ userId, gameId, code })` and `joinServer({ …, attributes })` for coded/private lobbies. `memoryPersistence()` / `filePersistence(dir)` (Node-only, still `@jgengine/node`) implement `HostPersistence`. `createGameWsServer({ host, port | server, authenticate?, poseRules?, positionHistoryMs? })` is now a thin `@jgengine/node` binding of `@jgengine/ws/hostRouter`'s `createHostRouter` onto the `ws` npm package (same public API + `RewoundPosition` re-export, versioned JSON protocol in `@jgengine/ws/protocol`, poses clamped server-side via `decidePoseSync`) and retains presence history for `rewind`. `toNodeHandler(webHandler)` (`@jgengine/node/webHandler`) bridges a fetch-standard `(Request) => Promise` handler onto `(IncomingMessage, ServerResponse)` for express/node servers — e.g. mounting `createReadsHandler(...)` on an express route. +- **WebSocket client** — `@jgengine/ws` `createWsBackend({ userId, url?, pipe? })` returns a `GameBackend` (plus `pushFeedEntry`, `browse` / `joinByCode` / `createSession`, `presenceSync` with client-side `poseSyncGate`); one of `url`/`pipe` is required — `url` opens the default `webSocketPipe`, `pipe` accepts any `TransportPipeFactory` (socket.io, WebRTC, loopback, custom). Browser-safe, imports core only. `createHttpReads({ baseUrl, gameId })` gives plain-fetch reads (`getTop / getLeaderboardProfile / getPlayerProfile / listOpenServers`) — no live-query dependency. `createReadsHandler({ persistence, basePath?, listOpenServers? })` (`@jgengine/ws/readsHandler`) is the server side of the same contract: a fetch-standard `(Request) => Promise` serving those four read routes, mountable directly in any framework's route handler (Next catch-all, TanStack Start server route); `persistence` accepts a lazy factory memoized on first request; `listOpenServers` lets a live `createGameHost` serve fresher listings than persistence has flushed. +- **Postgres** — `@jgengine/sql` `ensureSchema(pool)` + `sqlPersistence(pool)` implement `HostPersistence` over any pg-compatible pool (structural interface, no hard `pg` dep; tables `jg_game_servers`, `jg_player_profiles`, `jg_world_chunks`, `jg_leaderboard_rows`, `jg_feed_entries`). `HostPersistence.savePlan` applies a whole `ServerPersistPlan` in one transaction (leaderboard drain included); hosts fall back to per-tier calls when absent. +- **Clients** — `@jgengine/shell` (`GamePlayerShell`; each client supplies its own `GameRegistry`) is the shared player: it works in Vite, Next.js, or a Tauri webview; the authoritative ws host stays a standalone process (or, over `@jgengine/ws/peer`, the host player's own browser tab). +- **Shell multiplayer** — every resolver produces a `MultiplayerSession` (`ShellMultiplayer` is an alias of it). `resolveShellMultiplayer` resolves straight off `defineGame`'s `multiplayer` adapter config: `ws(...)` connects to `url ?? adapter.url ?? ws://localhost:8080/ws`; `lan(...)` derives `ws(s)://:` from `window.location`, so any browser on the LAN auto-connects to whichever machine served the page; other adapter kinds resolve to `null` (or fall back to the plain ws URL under `force` / the web dev route's `?ws` / desktop's `VITE_JG_WS_URL`). `resolvePeerShellMultiplayer({ gameId, role: "host" | "join", room?, userId?, feedActions? }): Promise` is the `p2p` counterpart — hosts or joins over `broadcastChannelSignaling`, no ws URL involved; `apps/dev` wires it behind `?p2p=host` / `?p2p=join`. `resolveConvexMultiplayer` is the `convex` counterpart (forced by `VITE_CONVEX_URL` the same way). `` then joins a server, pose-syncs the local player, renders remote players from the presence roster, bridges feed actions (default `entity.died`) both ways with echo suppression, and — when the backend exposes `chatSyncFor` — relays `global`-kind chat channels too. Game code unchanged either way. +- **Appearance replication.** Presence rows (`WsPresenceRow`) carry an optional per-slot `appearance?: WsAppearance` (`Record`) channel alongside pose — cosmetic ids, `"#rrggbb"` tints, model keys; slot semantics are game-defined, but the convention is slot `"tint"` = a hex tint the shell's `RemotePlayers` applies to recolor the remote capsule in place of the default hash color. Appearance rides the existing pose message — no protocol bump. The client pose-sync gate force-sends when appearance differs (shallow compare) even with zero movement, still rate-limited by `minIntervalMs`/heartbeat; the server accepts appearance-only changes and echoes the last-known appearance on every presence row. Wire `ctx.player.cosmetics.get(userId)` into the outgoing pose's `appearance` to replicate a player's equipped cosmetics for free. +- **Voice channels** — `@jgengine/ws/voiceChannel` (`createVoiceChannelRouter(channels?)`) is a thin, coarse layer on top of the same transport/presence model: it ships the channel/falloff **routing model**, not a WebRTC media stack (no audio transport — the media plane stays behind `multiplayer/voiceContract`'s `VoiceTransport` signaling seam: `join / leave / publish(streamId) / subscribers`; `createWsBackend(...).voiceSync` / `.voiceTransportFor(serverId)` implement it over `voiceJoin`/`voiceLeave`/`voicePublish` frames with host-relayed channel rosters, and `createLocalVoiceTransport()` covers local/dev; peers exchange stream **descriptors** here and negotiate actual media host-side). Mic capture + push-to-talk live in `@jgengine/react/voice` (`useVoice`, `createPushToTalk` state model: hold / toggle / openMic, mute-gated). `VoiceChannelDef = { id, positional, falloff?, gain? }` — `positional: true` channels (proximity voice) attenuate by distance using the same `@jgengine/core/audio/audioFalloff` curve as positional SFX; `positional: false` channels (walkie/crew) play at flat gain regardless of distance. A member `join`s any number of channels at once (a Sea of Thieves–style crew channel *and* nearby-ship proximity, simultaneously); `updatePosition(userId, xyz)` feeds positions (typically mirrored from `WsPresenceRow`); `setMuted(userId, bool)` silences every channel from that speaker at once. `resolveRoutes(listenerUserId)` returns one `{ fromUserId, channelId, gain }` per shared channel — the mixer plays each route independently, so the same speaker can be loud on `walkie` and near-silent on `proximity` at the same time. diff --git a/.claude/skills/jgengine-procedural/SKILL.md b/.claude/skills/jgengine-procedural/SKILL.md deleted file mode 100644 index 20d29c314..000000000 --- a/.claude/skills/jgengine-procedural/SKILL.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -name: jgengine-procedural -description: Procedural API: seeded gen, terrain, voxels, buildings, simulation worlds. ---- - -# jgengine-procedural - -## World features - -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.md](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-procedural/reference.md)**. - -## Turn-based & tactics (renderer-free) - diff --git a/.claude/skills/jgengine-procedural/api.md b/.claude/skills/jgengine-procedural/api.md deleted file mode 100644 index d34781098..000000000 --- a/.claude/skills/jgengine-procedural/api.md +++ /dev/null @@ -1,34 +0,0 @@ - - -# jgengine-procedural — exported API surface - -## @jgengine/core/survival/decayMeter - -- `DecayMeterConfig` (interface): interface DecayMeterConfig — ⚠ undocumented -- `DecayMeterSet` (interface): interface DecayMeterSet — ⚠ undocumented -- `DecayMeterState` (interface): interface DecayMeterState — ⚠ undocumented -- `MeterThreshold` (interface): interface MeterThreshold — ⚠ undocumented -- `createDecayMeterSet` (function): function createDecayMeterSet(configs: readonly DecayMeterConfig[]): DecayMeterSet — Named decay meters — hunger, thirst, oxygen, sanity, warmth, stamina. Each drains (or recovers) on game-time `dt` at a configurable rate, refills from consumables or actions, and raises moodle statuses at thresholds. Rate modifiers let the environment drive them (colder → faster warmth loss; toxic biome → oxygen drops), so a game reads an environment field then calls `setRateModifier`. - -## @jgengine/core/survival/moodle - -- `MOODLE_SEVERITY_ORDER` (const): const MOODLE_SEVERITY_ORDER: Record — ⚠ undocumented -- `Moodle` (interface): interface Moodle — ⚠ undocumented -- `MoodleSeverity` (type): type MoodleSeverity = "good" | "neutral" | "warning" | "critical" — ⚠ undocumented -- `MoodleSource` (type): type MoodleSource = "meter" | "ailment" | "buff" — ⚠ undocumented -- `MoodleStack` (interface): interface MoodleStack — ⚠ undocumented -- `TimedMoodleInput` (interface): interface TimedMoodleInput — ⚠ undocumented -- `createMoodleStack` (function): function createMoodleStack(): MoodleStack — A stateful holder for timed status moodles (food buffs, temporary shelter, warmth). Meters and multi-region health derive their own moodles on read; combine all three through `stackMoodles(stack.list(), meterMoodles, ailmentMoodles)` for one display. -- `stackMoodles` (function): function stackMoodles(...groups: readonly (readonly Moodle[])[]): Moodle[] — Merge any number of moodle groups into one stack — meters, ailments, and buffs share this display. Same-id moodles fold together (stacks add, worst severity wins); the result is ordered worst-first so the HUD reads critical statuses at a glance. - -## @jgengine/core/survival/regionHealth - -- `AilmentConfig` (interface): interface AilmentConfig — ⚠ undocumented -- `AilmentInstance` (interface): interface AilmentInstance — ⚠ undocumented -- `DamageResult` (interface): interface DamageResult — ⚠ undocumented -- `HealthRegionConfig` (interface): interface HealthRegionConfig — ⚠ undocumented -- `MultiRegionHealth` (interface): interface MultiRegionHealth — ⚠ undocumented -- `MultiRegionHealthConfig` (interface): interface MultiRegionHealthConfig — ⚠ undocumented -- `RegionHealthState` (interface): interface RegionHealthState — ⚠ undocumented -- `TreatResult` (interface): interface TreatResult — ⚠ undocumented -- `createMultiRegionHealth` (function): function createMultiRegionHealth(config: MultiRegionHealthConfig): MultiRegionHealth — Per-region/limb health tracked separately, so each body part takes and heals damage on its own. diff --git a/.claude/skills/jgengine-procedural/capabilities.md b/.claude/skills/jgengine-procedural/capabilities.md deleted file mode 100644 index b7ddb2e62..000000000 --- a/.claude/skills/jgengine-procedural/capabilities.md +++ /dev/null @@ -1,13 +0,0 @@ - - -# jgengine-procedural — capability index - -Reach for these before hand-rolling. Each row is *the thing you need* → *the primitive that already does it*. - -## decay-meter — survival meters that drain/refill over game time (hunger, water, oxygen, stamina) - -- `createDecayMeterSet` (function) · `import { createDecayMeterSet } from "@jgengine/core/survival/decayMeter"` - -## limb-health — per-body-part/region health tracked separately - -- `createMultiRegionHealth` (function) · `import { createMultiRegionHealth } from "@jgengine/core/survival/regionHealth"` diff --git a/.claude/skills/jgengine-procedural/reference.md b/.claude/skills/jgengine-procedural/reference.md deleted file mode 100644 index d1880cd2c..000000000 --- a/.claude/skills/jgengine-procedural/reference.md +++ /dev/null @@ -1,69 +0,0 @@ -# jgengine-procedural — Procedural worlds - -Use this reference for world descriptors, seeded terrain, grids, voxels, environment composition, query fields, scatter, weather, and generated structures. -## World features - -Descriptors from `@jgengine/core/world/features` — config data the runner/world layer interprets: - -| Feature | Use | -|---------|-----| -| `biomes({ map, zones, bounds? })` | Region atmosphere/rules layering; zones reference biome ids | -| `voxel({ seed, generate?, streaming? })` | Block worlds | -| `plots(config)` | Shared city + instanced interiors | -| `tilemap({ map })` | 2D/2.5D levels | -| `flat()` | Plain arena | -| `environment({ terrain, sky, weather, vegetation, water, structures, pads })` | Composable outdoor scene — terrain + sky/time-of-day + rain/snow + grass + ocean + buildings + ground pads. Each field takes the matching descriptor: `terrain()`, `sky()`, `rain()`/`snow()`, `grass()`, `ocean()`, `building()`, `pad()`. `building()` and `ocean()` take `position: [x, z]` to site a cluster/water body away from the origin (several settlements, an offset lake); building clusters ground-snap to the terrain field per building; each `pad()` (a flat platform/paved patch — `{ center, size: [w,d] | { radius }, height?, color?, rotationY? }`) implicitly flattens the terrain beneath it via a `TerrainFlattenMask`, so a building pad or spawn circle never fights the noise field underneath | - -`biomes`/`voxel`/`plots`/`tilemap` share a `WorldGridConfig` (`cells?: WorldGridCell[]`, `cellSize?`, `baseHeight?`, `defaultColor?`) — a `WorldGridCell` is `{ x, z, height?, color? }`, one extruded box per cell. `resolveGridInstances(config)` (`@jgengine/core/world/gridInstances`) is the pure cell→instance math (position, scale, color per cell); the shell renders the result as a single `THREE.InstancedMesh` **automatically whenever `PlayableGame.environment` is unset and `game.world` is one of these four grid kinds** — no manual render wiring for a cell-based world, same auto-render convention as `environment()` worlds. - -`terrain()`'s `material` (a named palette — `"grass" | "sand" | "snow" | "rock" | "ash"`, resolved via `resolveTerrainPalette`/`TERRAIN_MATERIAL_PALETTES` in `world/terrain`) sets the default low/high/waterline colors; `colors: { low?, high?, waterline? }` overrides any of them field-by-field, and `segments` tunes the render mesh's subdivision. `flatten: TerrainFlattenMask[]` (`{ center, radius, height?, falloff? }`) carves explicit flat circles into the noise field independent of pads — building foundations, spawn circles, roads — blending back to the noise height over `falloff` (default `radius * 0.5`). `sky({ preset?, timeOfDay?, horizonColor?, zenithColor?, sunIntensity?, ambientIntensity?, fog? })` — `preset: "day" | "dusk" | "night"` (default `"day"`) is the static look; `timeOfDay: true` instead drives sun position, sky colors, and fog from the world clock's `calendar().dayFraction` every frame (`@jgengine/shell`'s `TimeOfDayDaylight` mounts this automatically for an `environment()` world with `sky` set — no per-game render wiring). **Gotcha:** `sunIntensity` / `ambientIntensity` are honored only on the **day** keyframe (`daylightCycle` builds dusk/night/dawn from fixed constants). Raising intensities under `preset: "dusk"` or `"night"` does nothing — use `preset: "day"` plus warm `horizonColor`/`zenithColor` when the first screenshot must be bright and readable (see `jgengine`'s first-shot art recipe). - -`parentSpace` positions are local to that space — convert at seams only. - -### Query primitives (renderer-free, for gameplay) - -Pure `@jgengine/core` functions so gameplay reads the same world the shell renders — no three.js needed: - -| Primitive | Answers | -|-----------|---------| -| `resolveTerrainField(terrain(...))` / `noiseField(cfg)` → `TerrainField` | `sampleHeight(x,z)`, `sampleNormal(x,z)`, `waterLevel` — ground-snap, collision, camera. `resolveGroundStep` slope-limits movement | -| `snapToGround(field, position, offset?)` → `[x,y,z]` | Replaces a position's `y` with the field's sampled ground height (+ `offset`) at its `x`/`z` — the pure version of a spawn/placement ground-snap | -| `snapEntityToGround(entities, id, field, offset?)` → `boolean` | Ground-snaps an already-spawned entity in place via `entities.setPose` — false when `id` is unknown; the imperative sibling of `snapToGround` for "drop this entity onto the terrain right now" (mount dismount, teleport, respawn) | -| `windField(cfg)` → `WindField` | `at(t)`, `atPoint(x,z,t)`, `strengthAt` — one wind source for weather sway, grass, sailing, fire spread | -| `waterSurface(cfg)` / `waterSurfaceFromDescriptor(ocean(...))` → `WaterSurface` | `height(x,z,t)`, `normal`, `displace` — buoyancy, floating, shoreline (CPU Gerstner matching the ocean shader) | -| `scatter(cfg)` → `ScatterPoint[]` | Seeded, overlap-aware point distribution — vegetation, props, lots, spawn points (`minDistance`, `avoid` rects) | -| `createRegionField({ regions })` → `RegionField` | `sampleRegion(x,z)` blends content-agnostic biomes by nearest selector — height + `tint`/`water`/`fog`/`speedMultiplier` + opaque `data`. Extends `TerrainField`, so it ground-snaps too | -| `scatterItems(field, area, layersFor)` → `ScatterInstance[]` | Region-driven content scatter — density per region, grounded, above-water/slope-aware. `pickWeighted` for weighted rolls. (vs `scatter`'s pure geometric points) | -| `buildingIndex(district)` → `BuildingIndex` | `at`/`within`/`nearest`/`isInside`/`blockers` over a generated district — placement avoidance, pathfinding | - -**Voxel field (`world/voxelField`).** `createVoxelField({ chunkSize? })` (default 16) is a logical block lattice for voxel games and instanced renderers — distinct from the `voxel()` `WorldFeature` descriptor above (that's the runner-level world kind; this is the block data structure a voxel game's gameplay reads and writes). `set`/`remove`/`get`/`has`/`fill`/`clear`/`count`/`cells`/`bounds` are the block CRUD (`set` returns `false` only when the identical type is already there — a no-op write). `neighbors(x, y, z)` returns the 6-adjacent occupied cells; `exposedFaces(x, y, z)` returns the `VoxelFace`s (`"px"|"nx"|"py"|"ny"|"pz"|"nz"`) not touching another voxel — feed that straight into greedy-meshing/face-culling. `raycast(origin, direction, maxDistance)` runs a 3D DDA and returns `{ x, y, z, type, face, adjacent, distance }`, where `adjacent` is the empty cell just in front of the hit — the placement target for block-place tools. Renderers dirty-track via `chunkOf(x, y, z)` + `chunkVersion(chunk)` and `subscribe(listener)`, so an instanced mesh only rebuilds the chunks that changed. For a non-`environment()` voxel world, assert on `field.summary()` (`{ blocks, types, bounds }`) the same way an `environment()` world asserts on `summarizeEnvironment` (see `jgengine-verify`). - -**Destructible terrain (`world/carve`).** Two runtime-editable primitives for dig/carve worlds. `VoxelVolume` is a dense grid of material ids (0 = empty) — `carve({ center, radius, toolStrength })` clears a sphere of solid cells the tool is strong enough to break and returns the count removed (feed a loot roll), `deposit({ center, radius, material })` fills one (Deep Rock tunnels, Astroneer terrain add); `solidAtWorld` reads it back for collision. `CarvableField` (via `carvableTerrain(base)`) wraps any `TerrainField` and writes craters/mounds into its height — `carve({ x, z, radius, depth })`/`deposit({ x, z, radius, height })` — so ground-snap, collision, and the shell mesh all read the deformed surface (Helldivers 2 explosion craters). Cell strengths come from a `VoxelMaterial` table (DATA). Renders through `@jgengine/shell/terrain/CarvedTerrain`. - -Renderers for these descriptors live in `@jgengine/shell` (`shell/terrain`, `shell/water`, `shell/weather`, `shell/structures`). - -### Environment fields, weather hooks & realm composition -Renderer-free survival/environment primitives that extend the world query layer — meters, spawn gating, and damage-in-sunlight read the same world the shell renders, all ticking on game-time `dt`. -- **Environment field** (`world/envField`): `createEnvironmentField({ dayLength, baseTemperature, nightDrop, altitudeLapse, terrain, rain, occluders, heatSources, ambientFloor, temperatureAt })` → `EnvironmentField`. Sample **temperature**, **wetness**, **lightExposure** (direct sun/sky), and **ambientLight** (spawn gating) at any `(x, z, time)` — `sample(x, z, time, y?)` returns all four plus `sheltered`. Occluders (roofs/canopy) shade sun and shelter from rain; heat sources (campfires) warm nearby positions; `sunElevation(time)` drives the day cycle. Sun damages a vampire, cold forces campfires, low ambient light spawns mobs — the field answers "am I in sun vs. shade / cold vs. warm / dark vs. lit". Pure and instantaneous; stateful build-up belongs to a decay meter reading the field. -- **Weather → gameplay** (`world/weather`): `resolveWeather(state, table)` turns a `WeatherState { kind, intensity }` into concrete `ResolvedWeather` (`grip`, `visibility`, `structureDamage`, `chill`, `ignition`, `spread`) via a game-owned `WeatherModifierTable` — multipliers interpolate from neutral by intensity, rate effects scale linearly. Read `grip`/`visibility` in movement and AI, `structureDamage` on a building tick. -- **Fire spread** (`world/weather`): `createFireGrid({ cols, rows, cellSize, origin, fuelAt, spreadRate, burnRate, wind, windBias })` → `FireGrid` is a **coarse cellular** propagation (not a fluid solver): `ignite(x, z)` / `igniteCell(col, row)`, then `step(dt, { spread, wetnessAt })` transfers heat to neighbours biased by wind, consumes fuel (`unburnt → burning → burnt`), and honours firebreaks (zero-fuel cells) and rain/wetness suppression. `resolveWeather(...).spread` feeds the step; `@jgengine/shell/weather` `FireSpreadLayer` renders the burning/scorched cells. -- **Realm composition** (`world/realm`): `composeRealm(base, cards)` assembles a played instance at runtime from a deck of modifier **cards** (Nightingale realm cards) — a `major` card is the biome base, `minor` cards layer environment param overrides, a `WeatherState`, and spawn-table edits (`set`/`add`/`scale`/`remove`). The result recomposes both the environment (into a sampleable field via `composed.environmentField(extra?)`) and the `spawnTable`, and depends on the weather hooks above to turn its `weather` into gameplay modifiers. -### Survival meters, moodles & multi-region health -The `survival/` domain — decay-over-time meters and per-part health, both feeding one stacking **moodle** status display distinct from numeric bars. -- **Decay meters** (`survival/decayMeter`): `createDecayMeterSet([{ id, max, min?, start?, rate, thresholds }])` → `DecayMeterSet`. Each named meter (hunger, thirst, oxygen, sanity, warmth, stamina) drains/recovers on `tick(dt)` at `rate`, refills from consumables/actions via `refill(id, amount)`, and raises threshold moodles (`below`/`above`). `setRateModifier(id, mult)` lets the environment drive them — read an env field, then speed warmth loss when cold or oxygen loss in a toxic biome. -- **Moodles** (`survival/moodle`): the shared status stack, distinct from raw bars. `stackMoodles(...groups)` folds meter, ailment, and buff `Moodle[]` into one worst-first display (same-id stacks add, worst severity wins). `createMoodleStack()` holds timed buffs (`add({ id, label, duration })` — Valheim's concurrent food buffs) and expires them on `tick(dt)`. -- **Multi-region health** (`survival/regionHealth`): `createMultiRegionHealth({ regions, ailments })` → `MultiRegionHealth` gives per-part pools (head/thorax/arms/legs, Tarkov/DayZ style) — `damage(regionId, amount)` scales by `vulnerability` and kills when a `vital` part empties; a stacking **ailment queue** (`applyAilment`, `tick(dt)` drains like bleed) carries per-injury treatment (`treat(itemId)` clears wounds via bandage/tourniquet/splint). `ailmentMoodles()` shares the moodle display with the meters (#78 + #90). -### Interactive building & terraform (renderer-free tools) -Turn data-only placement into the build tooling of Valheim/Enshrouded/The Sims/Fortnite/Dinkum. All pure `@jgengine/core/world`; the shell renders the ghost/tint/brush (`shell/structures/PlacementGhost`, `shell/terrain/EditableGround`, `shell/terrain/TerraformBrushCursor`) driven by `pointer.worldHit()`. -| Primitive | Answers | -|-----------|---------| -| `createPlacementController({ footprint, rules, snapMode, grid })` | Owns the ghost: `hover(hit)` → `PlacementPreview` (`valid` tint wraps `validatePlacement`), `rotate()`, `setSnapMode`/`cycleSnapMode` (`"grid"`/`"free"`/`"surface"`), `commit()` → `PlacementCommit` (`rotationY` via `quarterTurnsToRotationY`). Feed it `pointer.worldHit()`. | -| `snapToNearest(registry, placed, movingDef, cursor, { snapDistance })` | Typed connector sockets — snaps a piece's socket onto the nearest **compatible** placed socket (`socketsCompatible` = both sides `accept` the other type). `worldSockets`/`socketWorldPosition` expand a piece's sockets to world space. | -| `solveSupport(pieces, links, { maxDistance })` → `SupportResult` | Walks the connector graph to any `grounded` piece: `supported` stays, `unsupported` collapses, `distance` (hops-to-ground) drives the white→red decay tint. `toDebrisBodies(pieces, unsupported)` → `AddBodyOptions[]` for the `PhysicsWorld` debris sink. | -| `createWallDrawTool({ snap, closeTolerance })` | Drag wall points → auto-encloses when the path returns to the start (`isEnclosed`), `footprint()` derives the room `EnclosedFootprint`, `roof()` auto-fits a hip/gable/flat `RoofPlan`. `createSurfacePaint()` stores per-tile floor/wall surfaces. | -| `createPlacedStructureStore()` | Save/load a built layout: `add`/`move`/`rotate`/`remove`/`select`, `snapshot()`↔`load()` round-trip (survives reload), `subscribe` for the renderer. | -| `createEditableTerrain({ bounds, base, cellSize })` → `EditableTerrain` | A `TerrainField` you can **write back to**: `apply(edit: TerraformEdit)` raises/lowers/flattens/paints under a cursor and re-samples `sampleHeight`; `surfaceAt`, `snapshot`/`restore`, `reset`. `createTerraformBrush(field)` is the cursor tool (`raise`/`lower`/`flatten`/`paint`, radius/strength). This write-back grid is the shared terrain-edit pattern. | -| `createPlotPermissions({ plotId, ownerId, guildId? })` + `createContributionPool(goal)` | Per-plot/guild edit authority (`canEdit`/`canView`, `grant`/`revoke` `BuildRole`, guild inheritance) for co-op building, plus a pooled-resource contribution model (`contribute` caps at the goal, reports overflow, `isComplete`, per-contributor totals). | - -### Physics world (optional, headless) - diff --git a/.claude/skills/jgengine-ui/api.md b/.claude/skills/jgengine-ui/api.md index 63747a1ba..c1f4b2bec 100644 --- a/.claude/skills/jgengine-ui/api.md +++ b/.claude/skills/jgengine-ui/api.md @@ -643,6 +643,10 @@ - `GameHost` (function): function GameHost({ playable, gameId, wsUrl, multiplayer, resolveMultiplayer }: GameHostProps): React.JSX.Element — ⚠ undocumented - `GameHostProps` (interface): interface GameHostProps — ⚠ undocumented +## @jgengine/shell/GamePhaseStamp + +- `GamePhaseStamp` (function): function GamePhaseStamp(): null — ⚠ undocumented + ## @jgengine/shell/GamePlayer - `GamePlayer` (function): function GamePlayer({ gameId, registry, fallbackGameId, loading = null, multiplayer = null }: GamePlayerProps): React.JSX.Element — ⚠ undocumented @@ -651,10 +655,6 @@ ## @jgengine/shell/GamePlayerShell - `GamePlayerShell` (function): function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null, poster = false, onContextReady, }: { playable: PlayableGame; multiplayer?: ShellMultiplayer | null; poster?: boolean; /** Called once per boot after onInit/onNewPlayer with the live GameContext — a staging seam for screenshots,… — ⚠ undocumented -- `applyMotionImpulses` (function): function applyMotionImpulses(currentVelocity: number, batch: MotionIntentBatch | null): number — Fold a batch's vertical impulses into a controller's velocity, then apply an outright `setVerticalVelocity` override — the vertical counterpart of {@link applyHorizontalImpulses}. -- `hasEnvironmentTerrain` (function): function hasEnvironmentTerrain(world: WorldFeature | undefined): boolean — Whether a world declares real terrain (base heightfield or islands) rather than a flat plane — gates terrain-floor sampling in the movement controllers. -- `nearbyObstacles` (function): function nearbyObstacles(objects: readonly { position: readonly [number, number, number]; }[], center: readonly [number, number, number], radius?: number): CollisionObstacle[] — Placed objects within `radius` (XZ) of `center`, as {@link CollisionObstacle}s to pre-filter for {@link resolveObstacleStep}. -- `resolvePhysicsTuning` (function): function resolvePhysicsTuning(physics: PhysicsConfig | undefined): MovementTuningOverrides | undefined — Maps a game's declared `physics` onto the movement controllers' tuning. `PhysicsConfig.gravity` is a signed world acceleration (negative points down), but the controllers integrate `velocityY -= gravityAcceleration * dt` and expect a positive downward magnitude — so gravity is negated here to keep down-pointing gravity pulling down. ## @jgengine/shell/GameUiPreview @@ -772,7 +772,7 @@ ## @jgengine/shell/camera/fovPreference -- `PLAYER_FOV_DEFAULT` (const): const PLAYER_FOV_DEFAULT: 55 — ⚠ undocumented +- `PLAYER_FOV_DEFAULT` (const): const PLAYER_FOV_DEFAULT: any — ⚠ undocumented - `PLAYER_FOV_MAX` (const): const PLAYER_FOV_MAX: 120 — ⚠ undocumented - `PLAYER_FOV_MIN` (const): const PLAYER_FOV_MIN: 40 — ⚠ undocumented - `PLAYER_FOV_STORAGE_KEY` (const): const PLAYER_FOV_STORAGE_KEY: "jgengine:player-fov" — ⚠ undocumented @@ -780,8 +780,6 @@ ## @jgengine/shell/camera/inspectionCameraMath -- `InspectionCameraConfig` (interface): interface InspectionCameraConfig — Model-viewer / inspection rig (#207.7) — orbit + pan + anchored zoom around a fixed point, never reads player input. -- `InspectionZoomAnchor` (type): type InspectionZoomAnchor = "target" | "cursor" | "center" — How scroll-zoom re-anchors the view for the inspection rig (#207.7): - `target` — dolly toward the orbit target (classic OrbitControls behavior). - `cursor` — dolly toward the point under the pointer. - `center` — dolly toward the viewport center; equivalent to `target` for an OrbitControls-driven rig, since the camera always faces `target` and that point already projects to the exact center of the viewport. - `ResolvedInspectionCameraConfig` (interface): interface ResolvedInspectionCameraConfig — ⚠ undocumented ## @jgengine/shell/camera/orbitCameraMath @@ -835,10 +833,34 @@ - `GameConfig` (type): type GameConfig = EngineFields & PresentationFields — ⚠ undocumented - `defineGame` (function): function defineGame(config: GameConfig): PlayableGame — ⚠ undocumented +## @jgengine/shell/devtools/ColPanel + +- `ColPanel` (function): function ColPanel(): React.JSX.Element — ⚠ undocumented + ## @jgengine/shell/devtools/CollisionDebugWorld - `CollisionDebugWorld` (function): function CollisionDebugWorld(): React.JSX.Element | null — World-space collision debugger. Performs zero scene scans and zero raycasts when every layer is off. Mount only when shell devtools are enabled. +## @jgengine/shell/devtools/KeysPanel + +- `KeysPanel` (function): function KeysPanel({ input }: { input: ActionCodesMap | undefined }): React.JSX.Element — ⚠ undocumented + +## @jgengine/shell/devtools/LogsPanel + +- `LogsPanel` (function): function LogsPanel(): React.JSX.Element — ⚠ undocumented + +## @jgengine/shell/devtools/NetPanel + +- `NetPanel` (function): function NetPanel({ multiplayer }: { multiplayer: ShellMultiplayer | null }): React.JSX.Element — ⚠ undocumented + +## @jgengine/shell/devtools/PerfPanel + +- `PerfPanel` (function): function PerfPanel({ ctx }: { ctx: GameContext }): React.JSX.Element — ⚠ undocumented + +## @jgengine/shell/devtools/TunePanel + +- `TunePanel` (function): function TunePanel({ gameName }: { gameName: string }): React.JSX.Element — ⚠ undocumented + ## @jgengine/shell/devtools/agentBridge - `AgentBridgeRequest` (type): type AgentBridgeRequest = { method: string } & Record — One RPC call into the agent bridge: a verb name plus its verb-specific fields. @@ -871,6 +893,36 @@ - `HITBOX_WIRE_COLOR` (const): const HITBOX_WIRE_COLOR: "#f472b6" — ⚠ undocumented - `PROJECTILE_PATH_COLOR` (const): const PROJECTILE_PATH_COLOR: "#fde68a" — ⚠ undocumented +## @jgengine/shell/devtools/devtoolsOverrides + +- `readStoredOverrides` (function): function readStoredOverrides(gameName: string): DevtoolsOverrides | null — ⚠ undocumented + +## @jgengine/shell/devtools/panelAtoms + +- `SectionLabel` (function): function SectionLabel({ children }: { children: string }): React.JSX.Element — ⚠ undocumented +- `StatRow` (function): function StatRow({ name, value, alert }: { name: string; value: string; alert?: boolean }): React.JSX.Element — ⚠ undocumented +- `ms` (function): function ms(value: number): string — ⚠ undocumented + +## @jgengine/shell/devtools/perfDiagnose + +- `diagnose` (function): function diagnose(frame: NonNullable>, longs: readonly LongFrameEvent[]): string | null — ⚠ undocumented + +## @jgengine/shell/diagnostics/RuntimeDiagnostics + +- `DiagnosticOverlay` (function): function DiagnosticOverlay({ diagnostics, gameName }: { diagnostics: RuntimeDiagnostic[]; gameName: string }): React.JSX.Element | null — ⚠ undocumented +- `GameUiErrorBoundary` (class): class GameUiErrorBoundary extends Component< { children: ReactNode; onRuntimeError: (error: unknown, phase: string, componentStack?: string) => void }, { failed: boolean } > — ⚠ undocumented +- `RuntimeDiagnostic` (interface): interface RuntimeDiagnostic — ⚠ undocumented +- `logRuntimeError` (function): function logRuntimeError(error: unknown, phase: string, componentStack?: string): Omit — ⚠ undocumented + +## @jgengine/shell/drivers/FrameDriver + +- `FrameDriver` (function): function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef, pointerAxisRef, gateRef, onRuntimeError, multiplayer, serverIdRef, pointerService, pointerAim, pingCommand, poster, onPosterSettled, }: { ctx: GameContext; playable: PlayableGame; tracker: ActionStateTracker; y… — ⚠ undocumented +- `POSTER_SETTLE_SECONDS` (const): const POSTER_SETTLE_SECONDS: 1.6 — ⚠ undocumented + +## @jgengine/shell/drivers/HudOnlyDriver + +- `HudOnlyDriver` (function): function HudOnlyDriver({ ctx, playable, tracker, pointerAxisRef, gateRef, onRuntimeError, }: { ctx: GameContext; playable: PlayableGame; tracker: ActionStateTracker; pointerAxisRef: { current: PointerAxisState | null }; gateRef: { current: boolean }; onRuntimeError: (error: unknown, phase: s… — ⚠ undocumented + ## @jgengine/shell/environment - `DaylightCycleConfig` (interface): interface DaylightCycleConfig — ⚠ undocumented @@ -1022,6 +1074,17 @@ - `RenderObject` (type): type RenderObject = (object: SceneObject) => ReactNode — ⚠ undocumented - `resolveGameLoader` (function): function resolveGameLoader(registry: GameRegistry, gameId: string, fallbackGameId?: string): (() => Promise) | undefined — ⚠ undocumented +## @jgengine/shell/render/SceneLighting + +- `BackdropFog` (function): function BackdropFog({ fog }: { fog: BackdropConfig["fog"] }): React.JSX.Element | null — ⚠ undocumented +- `ConfiguredLighting` (function): function ConfiguredLighting({ lighting }: { lighting: LightingConfig }): React.JSX.Element — ⚠ undocumented + +## @jgengine/shell/render/SceneModels + +- `EntityModel` (function): function EntityModel({ model, instanceId }: { model: ModelConfig; instanceId?: string }): React.JSX.Element — ⚠ undocumented +- `EntitySprite` (function): function EntitySprite({ sprite }: { sprite: EntitySpriteConfig }): React.JSX.Element — ⚠ undocumented +- `IsolatedEntityModel` (function): function IsolatedEntityModel({ model, instanceId, fallback, }: { model: ModelConfig; instanceId?: string; fallback?: ReactNode; }): React.JSX.Element — ⚠ undocumented + ## @jgengine/shell/render/modelRender - `MaterialCache` (interface): interface MaterialCache — ⚠ undocumented @@ -1112,15 +1175,14 @@ ## @jgengine/shell/settings/settingsController -- `SettingsActionView` (interface): interface SettingsActionView — A resolved game-state action — `run` is already bound to the game context and closes the menu. -- `SettingsCategoryView` (interface): interface SettingsCategoryView — A settings menu category with its rows and keybinds, ready to render. -- `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. - `SettingsControllerInput` (interface): interface SettingsControllerInput — ⚠ undocumented -- `SettingsKeybindRow` (interface): interface SettingsKeybindRow — One rebindable action row rendered in the controls settings category. -- `SettingsRow` (interface): interface SettingsRow — One editable setting rendered in a settings menu category. -- `bindingLabel` (function): function bindingLabel(code: string): string — Short display label for a raw key/button code (e.g. `"KeyW"` → `"W"`). - `useSettingsCategories` (function): function useSettingsCategories(config: SettingsControllerInput): SettingsCategoryView[] — ⚠ undocumented +## @jgengine/shell/shellConstants + +- `EMPTY_RESERVED` (const): const EMPTY_RESERVED: ReadonlySet — No action names are reserved when no camera rig is active (hud/none presentation): games may bind `turnLeft`/`interact`/etc. as their own. +- `NO_ACTIONS` (const): const NO_ACTIONS: string[] — Empty action list — published while the orientation gate is up to suppress all held input without touching the tracker. + ## @jgengine/shell/structures - `BuildingBlock` (function): function BuildingBlock({ part, palette }: BuildingBlockProps): React.JSX.Element — ⚠ undocumented @@ -1174,7 +1236,6 @@ - `EditableGround` (function): function EditableGround({ terrain, bounds, segments = 96, version = 0, baseColor = "#3f6b3a", surfaceColors = DEFAULT_SURFACE_COLORS, }: EditableGroundProps): React.JSX.Element — ⚠ undocumented - `EditableGroundProps` (interface): interface EditableGroundProps — ⚠ undocumented - `FieldGroundOptions` (interface): interface FieldGroundOptions — ⚠ undocumented -- `FractalNoiseConfig` (interface): interface FractalNoiseConfig — Octave settings for {@link fractalNoise}: frequency, layering, and optional ridged shaping. - `GrassBladeGeometryOptions` (interface): interface GrassBladeGeometryOptions — ⚠ undocumented - `GrassField` (function): function GrassField({ count = DEFAULT_GRASS_COUNT, density = DEFAULT_GRASS_DENSITY, budget, area = 40, seed = 1, segments = 4, bladeHeight, bladeWidth, bladeBend, heightAt, colorBase, colorTip, colorVariation, wind, roughness, castShadow = false, receiveShadow = true, frustumCulled = true, ...meshPr… — ⚠ undocumented - `GrassFieldProps` (interface): interface GrassFieldProps extends Omit — ⚠ undocumented @@ -1183,7 +1244,6 @@ - `GrassRange` (type): type GrassRange = number | readonly [min: number, max: number] — ⚠ undocumented - `GrassShaderUniforms` (interface): interface GrassShaderUniforms — ⚠ undocumented - `GrassWindOptions` (interface): interface GrassWindOptions — ⚠ undocumented -- `NoiseFieldConfig` (interface): interface NoiseFieldConfig — Configuration for {@link noiseField}: seed, amplitude, and fractal noise shaping. - `ProceduralGround` (function): function ProceduralGround({ terrain, colors, roughness = 0.94, metalness = 0, receiveShadow = true, ...meshProps }: ProceduralGroundProps): React.JSX.Element — ⚠ undocumented - `ProceduralGroundProps` (interface): interface ProceduralGroundProps extends Omit — ⚠ undocumented - `ProceduralTerrainConfig` (interface): interface ProceduralTerrainConfig — ⚠ undocumented @@ -1193,19 +1253,9 @@ - `TerraformBrushCursor` (function): function TerraformBrushCursor({ center, y = 0.05, radius, mode }: TerraformBrushCursorProps): React.JSX.Element | null — ⚠ undocumented - `TerraformBrushCursorProps` (interface): interface TerraformBrushCursorProps — ⚠ undocumented - `TerrainArea` (type): type TerrainArea = number | readonly [width: number, depth: number] — ⚠ undocumented -- `TerrainField` (interface): interface TerrainField — A sampleable ground surface: height and normal at any x/z, with optional bounds and water level. - `TerrainHeightSampler` (type): type TerrainHeightSampler = (x: number, z: number) => number — ⚠ undocumented -- `TerrainNormal` (type): type TerrainNormal = readonly [number, number, number] — A surface normal vector at a terrain sample point. - `TerrainSeed` (type): type TerrainSeed = number | string — ⚠ undocumented - `TerrainVertexColorOptions` (interface): interface TerrainVertexColorOptions — ⚠ undocumented -- `arenaField` (function): function arenaField(config?: ArenaFieldConfig): TerrainField — Builds a `TerrainField` with a flat spawn plateau, rolling hills, and a basin, for combat arenas. -- `flatField` (function): function flatField(): TerrainField — A flat, zero-height `TerrainField` for arenas with no elevation. -- `fractalNoise` (function): function fractalNoise(x: number, z: number, config: FractalNoiseConfig): number — Layers `valueNoise` octaves per `config` into a single normalized noise sample. -- `noiseField` (function): function noiseField(config?: NoiseFieldConfig): TerrainField — Builds a `TerrainField` whose height is fractal noise shaped by `config`. -- `resolveGroundStep` (function): function resolveGroundStep(field: TerrainField, x: number, z: number, stepX: number, stepZ: number, maxSlope?: number): { stepX: number; stepZ: number; } — Zeroes out a movement step's x or z component where it would climb steeper than `maxSlope`. -- `resolveTerrainField` (function): function resolveTerrainField(descriptor?: TerrainEnvironmentDescriptor): TerrainField — Resolves a `TerrainEnvironmentDescriptor` into a concrete `TerrainField`, applying flatten masks. -- `valueNoise` (function): function valueNoise(x: number, z: number, seed: number): number — Smoothly interpolated 2D value noise in `[-1, 1]` for the given seed. -- `withNormal` (function): function withNormal(sampleHeight: (x: number, z: number) => number): TerrainField["sampleNormal"] — Derives a `TerrainField.sampleNormal` from a height sampler via finite-difference gradients. ## @jgengine/shell/terrain @@ -1215,7 +1265,6 @@ - `EditableGround` (function): function EditableGround({ terrain, bounds, segments = 96, version = 0, baseColor = "#3f6b3a", surfaceColors = DEFAULT_SURFACE_COLORS, }: EditableGroundProps): React.JSX.Element — ⚠ undocumented - `EditableGroundProps` (interface): interface EditableGroundProps — ⚠ undocumented - `FieldGroundOptions` (interface): interface FieldGroundOptions — ⚠ undocumented -- `FractalNoiseConfig` (interface): interface FractalNoiseConfig — Octave settings for {@link fractalNoise}: frequency, layering, and optional ridged shaping. - `GrassBladeGeometryOptions` (interface): interface GrassBladeGeometryOptions — ⚠ undocumented - `GrassField` (function): function GrassField({ count = DEFAULT_GRASS_COUNT, density = DEFAULT_GRASS_DENSITY, budget, area = 40, seed = 1, segments = 4, bladeHeight, bladeWidth, bladeBend, heightAt, colorBase, colorTip, colorVariation, wind, roughness, castShadow = false, receiveShadow = true, frustumCulled = true, ...meshPr… — ⚠ undocumented - `GrassFieldProps` (interface): interface GrassFieldProps extends Omit — ⚠ undocumented @@ -1224,7 +1273,6 @@ - `GrassRange` (type): type GrassRange = number | readonly [min: number, max: number] — ⚠ undocumented - `GrassShaderUniforms` (interface): interface GrassShaderUniforms — ⚠ undocumented - `GrassWindOptions` (interface): interface GrassWindOptions — ⚠ undocumented -- `NoiseFieldConfig` (interface): interface NoiseFieldConfig — Configuration for {@link noiseField}: seed, amplitude, and fractal noise shaping. - `ProceduralGround` (function): function ProceduralGround({ terrain, colors, roughness = 0.94, metalness = 0, receiveShadow = true, ...meshProps }: ProceduralGroundProps): React.JSX.Element — ⚠ undocumented - `ProceduralGroundProps` (interface): interface ProceduralGroundProps extends Omit — ⚠ undocumented - `ProceduralTerrainConfig` (interface): interface ProceduralTerrainConfig — ⚠ undocumented @@ -1234,19 +1282,9 @@ - `TerraformBrushCursor` (function): function TerraformBrushCursor({ center, y = 0.05, radius, mode }: TerraformBrushCursorProps): React.JSX.Element | null — ⚠ undocumented - `TerraformBrushCursorProps` (interface): interface TerraformBrushCursorProps — ⚠ undocumented - `TerrainArea` (type): type TerrainArea = number | readonly [width: number, depth: number] — ⚠ undocumented -- `TerrainField` (interface): interface TerrainField — A sampleable ground surface: height and normal at any x/z, with optional bounds and water level. - `TerrainHeightSampler` (type): type TerrainHeightSampler = (x: number, z: number) => number — ⚠ undocumented -- `TerrainNormal` (type): type TerrainNormal = readonly [number, number, number] — A surface normal vector at a terrain sample point. - `TerrainSeed` (type): type TerrainSeed = number | string — ⚠ undocumented - `TerrainVertexColorOptions` (interface): interface TerrainVertexColorOptions — ⚠ undocumented -- `arenaField` (function): function arenaField(config?: ArenaFieldConfig): TerrainField — Builds a `TerrainField` with a flat spawn plateau, rolling hills, and a basin, for combat arenas. -- `flatField` (function): function flatField(): TerrainField — A flat, zero-height `TerrainField` for arenas with no elevation. -- `fractalNoise` (function): function fractalNoise(x: number, z: number, config: FractalNoiseConfig): number — Layers `valueNoise` octaves per `config` into a single normalized noise sample. -- `noiseField` (function): function noiseField(config?: NoiseFieldConfig): TerrainField — Builds a `TerrainField` whose height is fractal noise shaped by `config`. -- `resolveGroundStep` (function): function resolveGroundStep(field: TerrainField, x: number, z: number, stepX: number, stepZ: number, maxSlope?: number): { stepX: number; stepZ: number; } — Zeroes out a movement step's x or z component where it would climb steeper than `maxSlope`. -- `resolveTerrainField` (function): function resolveTerrainField(descriptor?: TerrainEnvironmentDescriptor): TerrainField — Resolves a `TerrainEnvironmentDescriptor` into a concrete `TerrainField`, applying flatten masks. -- `valueNoise` (function): function valueNoise(x: number, z: number, seed: number): number — Smoothly interpolated 2D value noise in `[-1, 1]` for the given seed. -- `withNormal` (function): function withNormal(sampleHeight: (x: number, z: number) => number): TerrainField["sampleNormal"] — Derives a `TerrainField.sampleNormal` from a height sampler via finite-difference gradients. ## @jgengine/shell/terrain/CarvedTerrain @@ -1326,6 +1364,10 @@ - `primaryButtonOffsets` (function): function primaryButtonOffsets(count: number, scale = 1): { right: number; bottom: number }[] | null — Thumb-arc placement for primary buttons around the bottom-right corner: up to three on an inner ring, the rest on an outer ring. Null means too many buttons for an arc — the dock falls back to a wrapping grid. - `touchDockClearance` (function): function touchDockClearance(scheme: TouchScheme | null, scale = 1): number — Vertical space (px, excluding device safe areas) that *bottom-docked* clusters occupy above the bottom edge. The shell publishes it as `--jg-hud-dock-clearance` so `HudCanvas` regions never collide with touch controls. Side rails and top clusters reserve their own rectangles through the layout registry instead of this scalar. +## @jgengine/shell/useShellMultiplayerSync + +- `useShellMultiplayerSync` (function): function useShellMultiplayerSync(ctx: GameContext | null, multiplayer: ShellMultiplayer | null, playable: PlayableGame, serverIdRef: { current: string | null }, setRemotePlayers: Dispatch>): void — Joins the multiplayer server for the live context and wires presence, feed relay, and chat sync until teardown. + ## @jgengine/shell/visibility/CullingProvider - `CullingProvider` (function): function CullingProvider({ config, children }: { config: VisibilityConfig | undefined; children: ReactNode }): ReactNode — Drives automatic frustum + distance culling for every entity and placed object. It reads the live render camera each frame, updates the engine VisibilitySystem, and exposes a predicate the entity/object markers consult to toggle `group.visible` — objects fully outside the view (plus a conservative preload margin) are never submitted to the renderer, without unmounting them or touching gameplay. UI, sky, terrain, and environment live outside this subtree and are unaffected. @@ -1342,7 +1384,7 @@ - `SensorReadoutMeter` (function): function SensorReadoutMeter({ label, reading, className }: SensorReadoutMeterProps): React.JSX.Element — A handheld-sensor readout: needle strength bar + the raw reading, or a "no signal" idle state. - `SensorReadoutMeterProps` (interface): interface SensorReadoutMeterProps — ⚠ undocumented -- `useHiddenStateProbe` (function): function useHiddenStateProbe(origin: EntityPosition, sources: readonly HiddenStateSource[], options: SensorProbeOptions): SensorReading | null — Reads a hidden zone/entity state variable in range (EMF / thermometer / geiger style sensor verb, #116). +- `useHiddenStateProbe` (function): function useHiddenStateProbe(origin: EntityPosition, sources: readonly HiddenStateSource[], options: SensorProbeOptions): any — Reads a hidden zone/entity state variable in range (EMF / thermometer / geiger style sensor verb, #116). ## @jgengine/shell/vision/RevealVision @@ -1542,6 +1584,11 @@ - `WorldItems` (function): function WorldItems({ config }: { config?: WorldItemRenderConfig }): React.JSX.Element — Rarity→beam/color/label render binding + loot-filter overlay (#32/#33) for every dropped `worldItem`. +## @jgengine/shell/world/WorldScene + +- `RemotePlayers` (function): function RemotePlayers({ rows }: { rows: PresencePoseRow[] }): React.JSX.Element — ⚠ undocumented +- `WorldView` (function): function WorldView({ entitySprites, entityModels, objectModels, objectStyles, environment, assets, renderEntity, renderObject, selectedIds, hideLocalActor, }: { entitySprites: Record | undefined; entityModels: Record | undefined; objectModels… — ⚠ undocumented + ## @jgengine/shell/world/entityPose - `PoseSource` (interface): interface PoseSource — ⚠ undocumented diff --git a/.claude/skills/jgengine-ui/capabilities.md b/.claude/skills/jgengine-ui/capabilities.md index 525ff733a..0ee6784ee 100644 --- a/.claude/skills/jgengine-ui/capabilities.md +++ b/.claude/skills/jgengine-ui/capabilities.md @@ -4,10 +4,6 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the primitive that already does it*. -## camera-shake — calibrated trauma² camera kick with zero tuning - -- `traumaShake` (function) · `import { traumaShake } from "@jgengine/shell/camera"` - ## clock-format — format a signed time gap like a race split (+/- m:ss.ff) - `formatDelta` (function) · `import { formatDelta } from "@jgengine/core/format/duration"` diff --git a/.claude/skills/jgengine-world/SKILL.md b/.claude/skills/jgengine-world/SKILL.md index d373cad09..b4be6bb73 100644 --- a/.claude/skills/jgengine-world/SKILL.md +++ b/.claude/skills/jgengine-world/SKILL.md @@ -1,4 +1,4 @@ ---- +--- name: jgengine-world description: World API: movement, cameras, physics, maps, sensors, spawn placement. --- diff --git a/.claude/skills/jgengine-world/api.md b/.claude/skills/jgengine-world/api.md index c0fbe87df..86fa4a79c 100644 --- a/.claude/skills/jgengine-world/api.md +++ b/.claude/skills/jgengine-world/api.md @@ -69,6 +69,7 @@ - `SpawnDirectorConfig` (interface): interface SpawnDirectorConfig — ⚠ undocumented - `SpawnDirectorState` (interface): interface SpawnDirectorState — ⚠ undocumented - `SpawnEntry` (interface): interface SpawnEntry — ⚠ undocumented +- `SpawnPointBiasStrength` (type): type SpawnPointBiasStrength = "subtle" | "moderate" | "strong" — How strongly `distanceBias` weights candidates by distance from `avoid` — a named intent, not a weighting exponent. - `SpawnPointDistanceBias` (type): type SpawnPointDistanceBias = "near" | "far" | "none" — Preference for picking a spawn point relative to `avoid` positions: closer, farther, or unweighted. - `SpawnPointSelectionOptions` (interface): interface SpawnPointSelectionOptions — Semantic options for selecting a spawn point without exposing weighting internals. - `SpawnRequest` (interface): interface SpawnRequest — ⚠ undocumented @@ -453,6 +454,7 @@ - `JointOptions` (interface): interface JointOptions — ⚠ undocumented - `MAX_BROADPHASE_CELLS` (const): const MAX_BROADPHASE_CELLS: 1000000 — Cap on `nx*ny*nz` broadphase cells — guards a huge-bounds/tiny-cellSize config from hanging `step()`. - `PhysicsBounds` (interface): interface PhysicsBounds — ⚠ undocumented +- `PhysicsPrecision` (type): type PhysicsPrecision = "low" | "standard" | "high" — Simulation fidelity intent: `low` (cheap — many bodies, loose stacks, forgiving sleep), `standard` (default), `high` (tight stacks, accurate joints, worth the extra solver work). Sets the solver knobs below to a matched preset; an individual knob left explicit always wins over the preset. - `PhysicsStats` (interface): interface PhysicsStats — ⚠ undocumented - `PhysicsWorld` (class): class PhysicsWorld — ⚠ undocumented - `PhysicsWorldConfig` (interface): interface PhysicsWorldConfig — ⚠ undocumented @@ -1412,18 +1414,26 @@ - `ScatterRegion` (interface): interface ScatterRegion — A resolvable scatter region: a closed polygon footprint plus its fill rules. - `ScatterRegionRules` (interface): interface ScatterRegionRules — How a scatter region fills its polygon: density, spacing, variation, and masking rules. - `ScatterTerrain` (interface): interface ScatterTerrain — Ground sampler a scatter resolve reads height/normal from (the sculpt terrain or the game's ground). -- `clearanceZonesFrom` (function): function clearanceZonesFrom(doc: EditorDocument, options: ClearanceOptions = {}): AvoidZone[] — Point-pad clearance **discs** from a document's markers/volumes — the terrain-flatten set (spawns, plots, POIs get a level pad). A marker/volume contributes a disc when it carries `meta.clearance` or its kind is in `kinds`. Paths are *not* included (they render draped, never flattened — see {@link clearanceMasksFrom} for their foliage corridor). Pass `ids`/`kinds` to scope it. +- `clearanceZonesFrom` (function): function clearanceZonesFrom(doc: SceneDocumentLike, options: ClearanceOptions = {}): AvoidZone[] — Point-pad clearance **discs** from a document's markers/volumes — the terrain-flatten set (spawns, plots, POIs get a level pad). A marker/volume contributes a disc when it carries `meta.clearance` or its kind is in `kinds`. Paths are *not* included (they render draped, never flattened — see {@link clearanceMasksFrom} for their foliage corridor). Pass `ids`/`kinds` to scope it. - `distanceToPolygonEdge` (function): function distanceToPolygonEdge(point: Vec2, polygon: readonly Vec2[]): number — Shortest distance from a point to a polygon's boundary. -- `isScatterPath` (function): function isScatterPath(path: EditorPath): boolean — True when an editor path is a foliage/scatter region. +- `isScatterPath` (function): function isScatterPath(path: ScenePathLike): boolean — True when an editor path is a foliage/scatter region. - `pointInPolygon` (function): function pointInPolygon(point: Vec2, polygon: readonly Vec2[]): boolean — Ray-casting point-in-polygon test on the XZ plane. - `polygonArea` (function): function polygonArea(polygon: readonly Vec2[]): number — Shoelace area of a polygon (always non-negative), in square meters. - `polygonBounds` (function): function polygonBounds(polygon: readonly Vec2[]): Aabb | null — Axis-aligned bounds of a polygon, or null if it has no points. - `readScatterPalette` (function): function readScatterPalette(meta: Record | undefined): ScatterPaletteEntry[] — Parses a scatter region's palette from meta: a weighted `palette` array, else a single `item`. -- `readScatterRules` (function): function readScatterRules(path: EditorPath): ScatterRegionRules | null — The path's scatter rules with defaults filled in; null for non-scatter paths. -- `resolveScatter` (function): function resolveScatter(doc: EditorDocument, terrain?: ScatterTerrain, options: ResolveScatterOptions = {}): ScatterInstance[] — Every scatter region's placements across a document, grounded on `terrain` when provided. Regions honor clearance masks: their own manual `avoid` discs, plus (when the region's `autoAvoid` is on and `options.autoAvoid !== false`) the document-wide discs + path corridors from {@link clearanceMasksFrom} — so foliage auto-clears spawns, plots, and paths without hand-carving the polygon. +- `readScatterRules` (function): function readScatterRules(path: ScenePathLike): ScatterRegionRules | null — The path's scatter rules with defaults filled in; null for non-scatter paths. +- `resolveScatter` (function): function resolveScatter(doc: SceneDocumentLike, terrain?: ScatterTerrain, options: ResolveScatterOptions = {}): ScatterInstance[] — Every scatter region's placements across a document, grounded on `terrain` when provided. Regions honor clearance masks: their own manual `avoid` discs, plus (when the region's `autoAvoid` is on and `options.autoAvoid !== false`) the document-wide discs + path corridors from {@link clearanceMasksFrom} — so foliage auto-clears spawns, plots, and paths without hand-carving the polygon. - `resolveScatterRegion` (function): function resolveScatterRegion(region: ScatterRegion, terrain?: ScatterTerrain, avoid?: AvoidMasks): ScatterInstance[] — Deterministic placements for one scatter region: scatter its polygon footprint at `density` items/m² (respecting `minSpacing`), clip to the polygon, thin near the edge, drop placements outside the slope/height mask, and derive item/scale/yaw from the region id + seed — so the same saved region always grows the same field. Grounds each instance on `terrain` when provided. -- `scatterRegionEstimate` (function): function scatterRegionEstimate(path: EditorPath): { area: number; count: number } — Estimated placement count for a scatter path — density × polygon area, for a live UI readout. -- `scatterRegionFromPath` (function): function scatterRegionFromPath(path: EditorPath): ScatterRegion | null — Builds a resolvable {@link ScatterRegion} from a scatter path (XZ polygon + rules), or null. +- `scatterRegionEstimate` (function): function scatterRegionEstimate(path: ScenePathLike): { area: number; count: number } — Estimated placement count for a scatter path — density × polygon area, for a live UI readout. +- `scatterRegionFromPath` (function): function scatterRegionFromPath(path: ScenePathLike): ScatterRegion | null — Builds a resolvable {@link ScatterRegion} from a scatter path (XZ polygon + rules), or null. + +## @jgengine/core/world/sceneShapes + +- `SceneDocumentLike` (interface): interface SceneDocumentLike — The minimal document shape scatter/vegetation resolve against — markers, volumes, and paths only. Any `EditorDocument` satisfies it structurally; this module never imports the editor domain, so world stays the one-directional dependency editor already builds on. +- `SceneMarkerLike` (interface): interface SceneMarkerLike — The minimal point-object shape clearance reads from a document's markers; any `EditorMarker` satisfies it. +- `ScenePathLike` (interface): interface ScenePathLike — The minimal polyline shape {@link resolveScatterRegion} et al. read; any `EditorPath` satisfies it. +- `ScenePoint3` (interface): interface ScenePoint3 — A world-space point — structurally compatible with the editor's `EditorVec3`. +- `SceneVolumeLike` (interface): interface SceneVolumeLike — The minimal volume shape vegetation/clearance read; any `EditorVolume` satisfies it. ## @jgengine/core/world/segment diff --git a/.claude/skills/jgengine-world/capabilities.md b/.claude/skills/jgengine-world/capabilities.md index a971908e7..9e3abdb4b 100644 --- a/.claude/skills/jgengine-world/capabilities.md +++ b/.claude/skills/jgengine-world/capabilities.md @@ -20,9 +20,9 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `createPoseState` (function) · `import { createPoseState } from "@jgengine/core/movement/poseState"` -## dash-move — a dash/dodge burst with i-frames and cooldown +## entity-meta — cast-free narrow of SceneEntity.meta via a type guard -- `dashDisplacement` (function) · `import { dashDisplacement } from "@jgengine/core/movement/dash"` +- `entityMetaOf` (function) · `import { entityMetaOf } from "@jgengine/core/scene/entityStore"` ## follow-trail — trailing follower/snake formation that chases a leader @@ -72,10 +72,6 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `SOIL_KIND` (const) · `import { SOIL_KIND } from "@jgengine/core/world/soilKind"` -## visitor-loop — many-agent seek/travel/dwell/depart state machine over weighted POIs - -- `createVisitorLoop` (function) · `import { createVisitorLoop } from "@jgengine/core/ai/crowd"` - ## volumetric-clouds — raymarched cloud layer sky option - `VolumetricCloudsConfig` (interface) · `import { VolumetricCloudsConfig } from "@jgengine/core/world/volumetricClouds"` diff --git a/.claude/skills/jgengine-world/reference.md b/.claude/skills/jgengine-world/reference.md index 6155b5cc6..db4f73dc2 100644 --- a/.claude/skills/jgengine-world/reference.md +++ b/.claude/skills/jgengine-world/reference.md @@ -1,10 +1,10 @@ -# jgengine domain API — World features +# jgengine domain API — World features Reference module for the [`jgengine-world` API](SKILL.md) skill. Load this when you need the renderer-free world surface. ## World features -Descriptors from `@jgengine/core/world/features` — config data the runner/world layer interprets: +Descriptors from `@jgengine/core/world/features` — config data the runner/world layer interprets: | Feature | Use | |---------|-----| @@ -13,62 +13,62 @@ Descriptors from `@jgengine/core/world/features` — config data the runner | `plots(config)` | Shared city + instanced interiors | | `tilemap({ map })` | 2D/2.5D levels | | `flat()` | Plain arena | -| `environment({ terrain, sky, weather, vegetation, water, structures, pads })` | Composable outdoor scene — terrain + sky/time-of-day + rain/snow + grass + ocean + buildings + ground pads. Each field takes the matching descriptor: `terrain()`, `sky()`, `rain()`/`snow()`, `grass()`, `ocean()`, `building()`, `pad()`. `building()`, `ocean()`, `rain()`/`snow()`, and `grass()` take `position: [x, z]` — sugar for `area.position` — to site a cluster/water body/vegetation patch away from the origin (several settlements, an offset lake, a distant meadow), and a biome-banded world places grass by zone this way; building clusters ground-snap to the terrain field per building. **`rain()`/`snow()` are the exception:** their `area.position`/`position` are accepted for API symmetry but have **no visual effect** — `RainField`/`SnowField` always follow the camera and never apply the volume's transform, so `area.w/d/h` instead sizes a camera-anchored wrap-volume that must stay near camera scale (roughly 50-120 per axis) or the storm goes sub-pixel-invisible at district scale; dial `density`/`width` (rain)/`flakeSize` (snow)/`opacity` for a heavier storm instead of a bigger area; each `pad()` (a flat platform/paved patch — `{ center, size: [w,d] | { radius }, height?, color?, rotationY? }`) implicitly flattens the terrain beneath it via a `TerrainFlattenMask`, so a building pad or spawn circle never fights the noise field underneath. `grass({ density })` is **blades/m²** (default 4, matching `vegetation`'s density): blade count scales with patch **area**, capped at a per-field perf budget (`budget`/buffer `count`) — a devtools warning fires when a large patch is clamped, so raising density on a big field adds blades instead of silently no-opping against a fixed budget | +| `environment({ terrain, sky, weather, vegetation, water, structures, pads })` | Composable outdoor scene — terrain + sky/time-of-day + rain/snow + grass + ocean + buildings + ground pads. Each field takes the matching descriptor: `terrain()`, `sky()`, `rain()`/`snow()`, `grass()`, `ocean()`, `building()`, `pad()`. `building()`, `ocean()`, `rain()`/`snow()`, and `grass()` take `position: [x, z]` — sugar for `area.position` — to site a cluster/water body/vegetation patch away from the origin (several settlements, an offset lake, a distant meadow), and a biome-banded world places grass by zone this way; building clusters ground-snap to the terrain field per building. **`rain()`/`snow()` are the exception:** their `area.position`/`position` are accepted for API symmetry but have **no visual effect** — `RainField`/`SnowField` always follow the camera and never apply the volume's transform, so `area.w/d/h` instead sizes a camera-anchored wrap-volume that must stay near camera scale (roughly 50-120 per axis) or the storm goes sub-pixel-invisible at district scale; dial `density`/`width` (rain)/`flakeSize` (snow)/`opacity` for a heavier storm instead of a bigger area; each `pad()` (a flat platform/paved patch — `{ center, size: [w,d] | { radius }, height?, color?, rotationY? }`) implicitly flattens the terrain beneath it via a `TerrainFlattenMask`, so a building pad or spawn circle never fights the noise field underneath. `grass({ density })` is **blades/m²** (default 4, matching `vegetation`'s density): blade count scales with patch **area**, capped at a per-field perf budget (`budget`/buffer `count`) — a devtools warning fires when a large patch is clamped, so raising density on a big field adds blades instead of silently no-opping against a fixed budget | -`biomes`/`voxel`/`plots`/`tilemap` share a `WorldGridConfig` (`cells?: WorldGridCell[]`, `cellSize?`, `baseHeight?`, `defaultColor?`) — a `WorldGridCell` is `{ x, z, height?, color? }`, one extruded box per cell. `resolveGridInstances(config)` (`@jgengine/core/world/gridInstances`) is the pure cell→instance math (position, scale, color per cell); the shell renders the result as a single `THREE.InstancedMesh` **automatically whenever `PlayableGame.environment` is unset and `game.world` is one of these four grid kinds** — no manual render wiring for a cell-based world, same auto-render convention as `environment()` worlds. +`biomes`/`voxel`/`plots`/`tilemap` share a `WorldGridConfig` (`cells?: WorldGridCell[]`, `cellSize?`, `baseHeight?`, `defaultColor?`) — a `WorldGridCell` is `{ x, z, height?, color? }`, one extruded box per cell. `resolveGridInstances(config)` (`@jgengine/core/world/gridInstances`) is the pure cell→instance math (position, scale, color per cell); the shell renders the result as a single `THREE.InstancedMesh` **automatically whenever `PlayableGame.environment` is unset and `game.world` is one of these four grid kinds** — no manual render wiring for a cell-based world, same auto-render convention as `environment()` worlds. -`terrain()`'s `material` (a named palette — `"grass" | "sand" | "snow" | "rock" | "ash"`, resolved via `resolveTerrainPalette`/`TERRAIN_MATERIAL_PALETTES` in `world/terrain`) sets the default low/high/waterline colors; `colors: { low?, high?, waterline? }` overrides any of them field-by-field, and `segments` tunes the render mesh's subdivision. `biomeBands: BiomeBand[]` (`{ z, fade?, material?, colors? }`) cross-fades the **ground palette along the world's z axis** — ordered zones (vale → marsh → peaks) that blend across a `fade`-wide window (default 64) centered on each boundary; it's the linear counterpart to the radial `materialRegions` (which paint on top; each region is a `TerrainCircleRegion` (default), `TerrainPolylineRegion` (roads/rivers), or `TerrainRectRegion` (rotatable districts), sharing a `TerrainRegionStyle`). Pure/testable via `createTerrainPaletteSampler`/`createBiomeBandSampler` in `world/terrain`; the shell renders it through the same per-vertex `paletteAt` seam as regions, no extra wiring. (Per-zone **fog/sky** cross-fade isn't wired yet — three.js fog is scene-global — so bands drive ground color today.) `flatten: TerrainFlattenMask[]` (`{ center, radius, height?, falloff? }`) carves explicit flat circles into the noise field independent of pads — building foundations, spawn circles, roads — blending back to the noise height over `falloff` (default `radius * 0.5`). `detail: TerrainDetailConfig` (`{ rockColor?, sandColor?, snowColor?, rockSlopeStart?, snowHeight?, waterLevel?, detailScale?, macroScale?, roughness?, strength? }`) swaps the flat vertex-colour ground for a procedural noise shader (`@jgengine/shell`'s `createTerrainDetailMaterial`, defaults via `resolveTerrainDetail`): it keeps the biome-tinted base and blends noise-broken **rock by slope**, **sand by waterline**, and **snow by height** over it — textured-reading terrain with no image assets, full PBR (lit/shadowed/fogged), composing with the post-processing chain. Omit for the flat look. `detail.material: { maps, repeat?, strength? }` (`TerrainDetailMaterialConfig`) is the real-texture seam: `maps` is `buildMaterialCatalog({ basePath }).resolve("material/grass")!.maps` from `@jgengine/assets` (or any URLs with the same `{ color, normal, roughness, ao, displacement }` shape) — `resolveTerrainDetail` fills `repeat` (world units per tile, default 4) and `strength` (blend 0..1, default 1), and the shell's `createTerrainDetailMaterial(detail, textures)` tiles the loaded color/roughness/ao/normal by world position and blends them over the same procedural rock/sand/snow result, never replacing it. The shell resolves the map URLs to `THREE.Texture`s itself (`useTexture`/`useLoader`, e.g. in the game's `environment` component) — `core` only ever sees the URL strings. Detail noise is coordinate-stable at any world position — the fragment hash is integer/position-based, so `detailScale` keeps full fine detail far from origin (a playfield at `|x|≈550` reads the same as one near origin; the old sin-hash flattened past `worldPos/scale ≳ 100`). `sky({ preset?, timeOfDay?, horizonColor?, zenithColor?, sunIntensity?, ambientIntensity?, radius?, hazeStrength?, sunGlowStrength?, fog? })` — `preset: "day" | "dusk" | "night"` (default `"day"`) is the static look; `timeOfDay: true` instead drives sun position, sky colors, and fog from the world clock's `calendar().dayFraction` every frame (`@jgengine/shell`'s `TimeOfDayDaylight` mounts this automatically for an `environment()` world with `sky` set — no per-game render wiring). The sky dome renders a bright HDR sun disc + warm glow (aligned to the sun light, so it blooms through a `postProcessing` chain), a mid-sky procedural cloud band, and a horizon haze — no HDRI assets; richer than a flat gradient with zero config. The dome **follows the camera every frame** (XZ), so it never leaves the viewer — but its `radius` (default 260) still has to clear your `fog.far` and any far scenery; a playfield sited far from the origin (or with distant landmarks) sets `radius` up to a few thousand so nothing pokes through the shell. `hazeStrength` (default 0.5) sizes the dusty horizon band and `sunGlowStrength` (default 1) the bloom around the sun disc — leave both unset for the stock look, raise/lower to dial a dusk/dust-storm horizon (the-robots uses `radius: 2600, hazeStrength: 0.62, sunGlowStrength: 0.6`). No game needs its own sky-dome mesh; configure `sky()` instead. **Gotcha:** `sunIntensity` / `ambientIntensity` are honored only on the **day** keyframe (`daylightCycle` builds dusk/night/dawn from fixed constants). Raising intensities under `preset: "dusk"` or `"night"` does nothing — use `preset: "day"` plus warm `horizonColor`/`zenithColor` when the first screenshot must be bright and readable (see `jgengine`'s first-shot art recipe). +`terrain()`'s `material` (a named palette — `"grass" | "sand" | "snow" | "rock" | "ash"`, resolved via `resolveTerrainPalette`/`TERRAIN_MATERIAL_PALETTES` in `world/terrain`) sets the default low/high/waterline colors; `colors: { low?, high?, waterline? }` overrides any of them field-by-field, and `segments` tunes the render mesh's subdivision. `biomeBands: BiomeBand[]` (`{ z, fade?, material?, colors? }`) cross-fades the **ground palette along the world's z axis** — ordered zones (vale → marsh → peaks) that blend across a `fade`-wide window (default 64) centered on each boundary; it's the linear counterpart to the radial `materialRegions` (which paint on top; each region is a `TerrainCircleRegion` (default), `TerrainPolylineRegion` (roads/rivers), or `TerrainRectRegion` (rotatable districts), sharing a `TerrainRegionStyle`). Pure/testable via `createTerrainPaletteSampler`/`createBiomeBandSampler` in `world/terrain`; the shell renders it through the same per-vertex `paletteAt` seam as regions, no extra wiring. (Per-zone **fog/sky** cross-fade isn't wired yet — three.js fog is scene-global — so bands drive ground color today.) `flatten: TerrainFlattenMask[]` (`{ center, radius, height?, falloff? }`) carves explicit flat circles into the noise field independent of pads — building foundations, spawn circles, roads — blending back to the noise height over `falloff` (default `radius * 0.5`). `detail: TerrainDetailConfig` (`{ rockColor?, sandColor?, snowColor?, rockSlopeStart?, snowHeight?, waterLevel?, detailScale?, macroScale?, roughness?, strength? }`) swaps the flat vertex-colour ground for a procedural noise shader (`@jgengine/shell`'s `createTerrainDetailMaterial`, defaults via `resolveTerrainDetail`): it keeps the biome-tinted base and blends noise-broken **rock by slope**, **sand by waterline**, and **snow by height** over it — textured-reading terrain with no image assets, full PBR (lit/shadowed/fogged), composing with the post-processing chain. Omit for the flat look. `detail.material: { maps, repeat?, strength? }` (`TerrainDetailMaterialConfig`) is the real-texture seam: `maps` is `buildMaterialCatalog({ basePath }).resolve("material/grass")!.maps` from `@jgengine/assets` (or any URLs with the same `{ color, normal, roughness, ao, displacement }` shape) — `resolveTerrainDetail` fills `repeat` (world units per tile, default 4) and `strength` (blend 0..1, default 1), and the shell's `createTerrainDetailMaterial(detail, textures)` tiles the loaded color/roughness/ao/normal by world position and blends them over the same procedural rock/sand/snow result, never replacing it. The shell resolves the map URLs to `THREE.Texture`s itself (`useTexture`/`useLoader`, e.g. in the game's `environment` component) — `core` only ever sees the URL strings. Detail noise is coordinate-stable at any world position — the fragment hash is integer/position-based, so `detailScale` keeps full fine detail far from origin (a playfield at `|x|≈550` reads the same as one near origin; the old sin-hash flattened past `worldPos/scale ≳ 100`). `sky({ preset?, timeOfDay?, horizonColor?, zenithColor?, sunIntensity?, ambientIntensity?, radius?, hazeStrength?, sunGlowStrength?, fog? })` — `preset: "day" | "dusk" | "night"` (default `"day"`) is the static look; `timeOfDay: true` instead drives sun position, sky colors, and fog from the world clock's `calendar().dayFraction` every frame (`@jgengine/shell`'s `TimeOfDayDaylight` mounts this automatically for an `environment()` world with `sky` set — no per-game render wiring). The sky dome renders a bright HDR sun disc + warm glow (aligned to the sun light, so it blooms through a `postProcessing` chain), a mid-sky procedural cloud band, and a horizon haze — no HDRI assets; richer than a flat gradient with zero config. The dome **follows the camera every frame** (XZ), so it never leaves the viewer — but its `radius` (default 260) still has to clear your `fog.far` and any far scenery; a playfield sited far from the origin (or with distant landmarks) sets `radius` up to a few thousand so nothing pokes through the shell. `hazeStrength` (default 0.5) sizes the dusty horizon band and `sunGlowStrength` (default 1) the bloom around the sun disc — leave both unset for the stock look, raise/lower to dial a dusk/dust-storm horizon (the-robots uses `radius: 2600, hazeStrength: 0.62, sunGlowStrength: 0.6`). No game needs its own sky-dome mesh; configure `sky()` instead. **Gotcha:** `sunIntensity` / `ambientIntensity` are honored only on the **day** keyframe (`daylightCycle` builds dusk/night/dawn from fixed constants). Raising intensities under `preset: "dusk"` or `"night"` does nothing — use `preset: "day"` plus warm `horizonColor`/`zenithColor` when the first screenshot must be bright and readable (see `jgengine`'s first-shot art recipe). **Default look is cinematic (#773).** A 3D game gets a real sky + shadow-casting sun/hemisphere rig + tuned post stack out of the box (`look` unset ⇒ `"cinematic"`; `defineGame({ look: "flat" })` opts back to the bare rig). The preset **defers to a world that declares its own `sky`** — an `environment({ sky })` world keeps exactly the dome/time-of-day/lighting it configured; the preset only supplies a sky (and the post stack) to worlds that have none. So `sky()` tuning below still owns the look for scenes that set it. Full rig + resolver: `jgengine-ui` → "Rendering". **Flatten-mask composition semantics** (`withFlattenMasks` — easy to fight if guessed): masks apply **sequentially and the last matching mask wins outright**; there is no mask-to-mask blending. Inside `radius` the height becomes that mask's `target` exactly; in the `radius..radius+falloff` annulus it lerps from `target` back to the **raw noise height**, never to another mask's output. `target` defaults to the raw noise height at the mask's own center (`height` overrides). Consequences to know before composing roads or multi-site layouts: (1) a chain of default-height circles stair-steps, because each snaps to its own center's noise — give road masks explicit `height` values ramped between the endpoint site targets; (2) a later mask's falloff annulus punches raw-noise bumps through an earlier flat area — order masks so the surface that must win comes last, keep road `falloff` small (≤ radius), and skip road circles inside an already-flat site radius; (3) raw noise rarely herds — to guarantee canyon walls, emit masks with `height` *above* the surroundings flanking the corridor. A working road-ramp recipe (overlapping circles every ~12 units, height ramped between site targets, ramp clamped flat inside each site radius, wall masks alongside) lives in `Games/the-robots/src/game/world/level.ts`. -`parentSpace` positions are local to that space — convert at seams only. +`parentSpace` positions are local to that space — convert at seams only. ### Query primitives (renderer-free, for gameplay) -Pure `@jgengine/core` functions so gameplay reads the same world the shell renders — no three.js needed: +Pure `@jgengine/core` functions so gameplay reads the same world the shell renders — no three.js needed: | Primitive | Answers | |-----------|---------| -| `resolveTerrainField(terrain(...))` / `noiseField(cfg)` → `TerrainField` | `sampleHeight(x,z)`, `sampleNormal(x,z)`, `waterLevel` — ground-snap, collision, camera. `resolveGroundStep` slope-limits movement | -| `snapToGround(field, position, offset?)` → `[x,y,z]` | Replaces a position's `y` with the field's sampled ground height (+ `offset`) at its `x`/`z` — the pure version of a spawn/placement ground-snap | -| `snapEntityToGround(entities, id, field, offset?)` → `boolean` | Ground-snaps an already-spawned entity in place via `entities.setPose` — false when `id` is unknown; the imperative sibling of `snapToGround` for "drop this entity onto the terrain right now" (mount dismount, teleport, respawn) | -| `windField(cfg)` → `WindField` | `at(t)`, `atPoint(x,z,t)`, `strengthAt` — one wind source for weather sway, grass, sailing, fire spread | -| `waterSurface(cfg)` / `waterSurfaceFromDescriptor(ocean(...))` → `WaterSurface` | `height(x,z,t)`, `normal`, `displace` — buoyancy, floating, shoreline (CPU Gerstner matching the ocean shader) | -| `scatter(cfg)` → `ScatterPoint[]` | Seeded, overlap-aware point distribution — vegetation, props, lots, spawn points (`minDistance`, `avoid` rects) | -| `createRegionField({ regions })` → `RegionField` | `sampleRegion(x,z)` blends content-agnostic biomes by nearest selector — height + `tint`/`water`/`fog`/`speedMultiplier` + opaque `data`. Extends `TerrainField`, so it ground-snaps too | -| `scatterItems(field, area, layersFor)` → `ScatterInstance[]` | Region-driven content scatter — density per region, grounded, above-water/slope-aware. `pickWeighted` for weighted rolls. (vs `scatter`'s pure geometric points) | +| `resolveTerrainField(terrain(...))` / `noiseField(cfg)` → `TerrainField` | `sampleHeight(x,z)`, `sampleNormal(x,z)`, `waterLevel` — ground-snap, collision, camera. `resolveGroundStep` slope-limits movement | +| `snapToGround(field, position, offset?)` → `[x,y,z]` | Replaces a position's `y` with the field's sampled ground height (+ `offset`) at its `x`/`z` — the pure version of a spawn/placement ground-snap | +| `snapEntityToGround(entities, id, field, offset?)` → `boolean` | Ground-snaps an already-spawned entity in place via `entities.setPose` — false when `id` is unknown; the imperative sibling of `snapToGround` for "drop this entity onto the terrain right now" (mount dismount, teleport, respawn) | +| `windField(cfg)` → `WindField` | `at(t)`, `atPoint(x,z,t)`, `strengthAt` — one wind source for weather sway, grass, sailing, fire spread | +| `waterSurface(cfg)` / `waterSurfaceFromDescriptor(ocean(...))` → `WaterSurface` | `height(x,z,t)`, `normal`, `displace` — buoyancy, floating, shoreline (CPU Gerstner matching the ocean shader) | +| `scatter(cfg)` → `ScatterPoint[]` | Seeded, overlap-aware point distribution — vegetation, props, lots, spawn points (`minDistance`, `avoid` rects) | +| `createRegionField({ regions })` → `RegionField` | `sampleRegion(x,z)` blends content-agnostic biomes by nearest selector — height + `tint`/`water`/`fog`/`speedMultiplier` + opaque `data`. Extends `TerrainField`, so it ground-snaps too | +| `scatterItems(field, area, layersFor)` → `ScatterInstance[]` | Region-driven content scatter — density per region, grounded, above-water/slope-aware. `pickWeighted` for weighted rolls. (vs `scatter`'s pure geometric points) | | `resolveScatterRegion(region, terrain?)` / `resolveScatter(doc, terrain?)` | Deterministic instanced placements for a **freehand polygon** scatter region (the editor's foliage lasso): `density`/`minSpacing`/`seed`, `minScale`/`maxScale`, weighted `palette`, `maxSlope`/`minHeight`/`maxHeight` masks, `edgeFalloff` feathering, `alignToNormal`. Regions ride editor paths of `kind` `SCATTER_PATH_KIND` — `scatterRegionFromPath`/`readScatterRules`/`readScatterPalette` build one from a path's meta, `scatterRegionEstimate` gives the live count readout, `resolveScatter` walks every scatter path in a document. Polygon helpers: `pointInPolygon`, `distanceToPolygonEdge`, `polygonArea`, `polygonBounds`, `isScatterPath`. Same seed → same field; GPU-instanced, never one node per instance. | -| `buildingIndex(district)` → `BuildingIndex` | `at`/`within`/`nearest`/`isInside`/`blockers` over a generated district — placement avoidance, pathfinding | +| `buildingIndex(district)` → `BuildingIndex` | `at`/`within`/`nearest`/`isInside`/`blockers` over a generated district — placement avoidance, pathfinding | -**Voxel field (`world/voxelField`).** `createVoxelField({ chunkSize? })` (default 16) is a logical block lattice for voxel games and instanced renderers — distinct from the `voxel()` `WorldFeature` descriptor above (that's the runner-level world kind; this is the block data structure a voxel game's gameplay reads and writes). `set`/`remove`/`get`/`has`/`fill`/`clear`/`count`/`cells`/`bounds` are the block CRUD (`set` returns `false` only when the identical type is already there — a no-op write). `neighbors(x, y, z)` returns the 6-adjacent occupied cells; `exposedFaces(x, y, z)` returns the `VoxelFace`s (`"px"|"nx"|"py"|"ny"|"pz"|"nz"`) not touching another voxel — feed that straight into greedy-meshing/face-culling. `raycast(origin, direction, maxDistance)` runs a 3D DDA and returns `{ x, y, z, type, face, adjacent, distance }`, where `adjacent` is the empty cell just in front of the hit — the placement target for block-place tools. Renderers dirty-track via `chunkOf(x, y, z)` + `chunkVersion(chunk)` and `subscribe(listener)`, so an instanced mesh only rebuilds the chunks that changed. For a non-`environment()` voxel world, assert on `field.summary()` (`{ blocks, types, bounds }`) the same way an `environment()` world asserts on `summarizeEnvironment` (see `jgengine-verify`). +**Voxel field (`world/voxelField`).** `createVoxelField({ chunkSize? })` (default 16) is a logical block lattice for voxel games and instanced renderers — distinct from the `voxel()` `WorldFeature` descriptor above (that's the runner-level world kind; this is the block data structure a voxel game's gameplay reads and writes). `set`/`remove`/`get`/`has`/`fill`/`clear`/`count`/`cells`/`bounds` are the block CRUD (`set` returns `false` only when the identical type is already there — a no-op write). `neighbors(x, y, z)` returns the 6-adjacent occupied cells; `exposedFaces(x, y, z)` returns the `VoxelFace`s (`"px"|"nx"|"py"|"ny"|"pz"|"nz"`) not touching another voxel — feed that straight into greedy-meshing/face-culling. `raycast(origin, direction, maxDistance)` runs a 3D DDA and returns `{ x, y, z, type, face, adjacent, distance }`, where `adjacent` is the empty cell just in front of the hit — the placement target for block-place tools. Renderers dirty-track via `chunkOf(x, y, z)` + `chunkVersion(chunk)` and `subscribe(listener)`, so an instanced mesh only rebuilds the chunks that changed. For a non-`environment()` voxel world, assert on `field.summary()` (`{ blocks, types, bounds }`) the same way an `environment()` world asserts on `summarizeEnvironment` (see `jgengine-verify`). -**Destructible terrain (`world/carve`).** Two runtime-editable primitives for dig/carve worlds. `VoxelVolume` is a dense grid of material ids (0 = empty) — `carve({ center, radius, toolStrength })` clears a sphere of solid cells the tool is strong enough to break and returns the count removed (feed a loot roll), `deposit({ center, radius, material })` fills one (Deep Rock tunnels, Astroneer terrain add); `solidAtWorld` reads it back for collision. `CarvableField` (via `carvableTerrain(base)`) wraps any `TerrainField` and writes craters/mounds into its height — `carve({ x, z, radius, depth })`/`deposit({ x, z, radius, height })` — so ground-snap, collision, and the shell mesh all read the deformed surface (Helldivers 2 explosion craters). Cell strengths come from a `VoxelMaterial` table (DATA). Renders through `@jgengine/shell/terrain/CarvedTerrain`. +**Destructible terrain (`world/carve`).** Two runtime-editable primitives for dig/carve worlds. `VoxelVolume` is a dense grid of material ids (0 = empty) — `carve({ center, radius, toolStrength })` clears a sphere of solid cells the tool is strong enough to break and returns the count removed (feed a loot roll), `deposit({ center, radius, material })` fills one (Deep Rock tunnels, Astroneer terrain add); `solidAtWorld` reads it back for collision. `CarvableField` (via `carvableTerrain(base)`) wraps any `TerrainField` and writes craters/mounds into its height — `carve({ x, z, radius, depth })`/`deposit({ x, z, radius, height })` — so ground-snap, collision, and the shell mesh all read the deformed surface (Helldivers 2 explosion craters). Cell strengths come from a `VoxelMaterial` table (DATA). Renders through `@jgengine/shell/terrain/CarvedTerrain`. Renderers for these descriptors live in `@jgengine/shell` (`shell/terrain`, `shell/water`, `shell/weather`, `shell/structures`). ### Environment fields, weather hooks & realm composition -Renderer-free survival/environment primitives that extend the world query layer — meters, spawn gating, and damage-in-sunlight read the same world the shell renders, all ticking on game-time `dt`. -- **Environment field** (`world/envField`): `createEnvironmentField({ dayLength, baseTemperature, nightDrop, altitudeLapse, terrain, rain, occluders, heatSources, ambientFloor, temperatureAt })` → `EnvironmentField`. Sample **temperature**, **wetness**, **lightExposure** (direct sun/sky), and **ambientLight** (spawn gating) at any `(x, z, time)` — `sample(x, z, time, y?)` returns all four plus `sheltered`. Occluders (roofs/canopy) shade sun and shelter from rain; heat sources (campfires) warm nearby positions; `sunElevation(time)` drives the day cycle. Sun damages a vampire, cold forces campfires, low ambient light spawns mobs — the field answers "am I in sun vs. shade / cold vs. warm / dark vs. lit". Pure and instantaneous; stateful build-up belongs to a decay meter reading the field. -- **Weather → gameplay** (`world/weather`): `resolveWeather(state, table)` turns a `WeatherState { kind, intensity }` into concrete `ResolvedWeather` (`grip`, `visibility`, `structureDamage`, `chill`, `ignition`, `spread`) via a game-owned `WeatherModifierTable` — multipliers interpolate from neutral by intensity, rate effects scale linearly. Read `grip`/`visibility` in movement and AI, `structureDamage` on a building tick. -- **Fire spread** (`world/weather`): `createFireGrid({ cols, rows, cellSize, origin, fuelAt, spreadRate, burnRate, wind, windBias })` → `FireGrid` is a **coarse cellular** propagation (not a fluid solver): `ignite(x, z)` / `igniteCell(col, row)`, then `step(dt, { spread, wetnessAt })` transfers heat to neighbours biased by wind, consumes fuel (`unburnt → burning → burnt`), and honours firebreaks (zero-fuel cells) and rain/wetness suppression. `resolveWeather(...).spread` feeds the step; `@jgengine/shell/weather` `FireSpreadLayer` renders the burning/scorched cells. -- **Realm composition** (`world/realm`): `composeRealm(base, cards)` assembles a played instance at runtime from a deck of modifier **cards** (Nightingale realm cards) — a `major` card is the biome base, `minor` cards layer environment param overrides, a `WeatherState`, and spawn-table edits (`set`/`add`/`scale`/`remove`). The result recomposes both the environment (into a sampleable field via `composed.environmentField(extra?)`) and the `spawnTable`, and depends on the weather hooks above to turn its `weather` into gameplay modifiers. +Renderer-free survival/environment primitives that extend the world query layer — meters, spawn gating, and damage-in-sunlight read the same world the shell renders, all ticking on game-time `dt`. +- **Environment field** (`world/envField`): `createEnvironmentField({ dayLength, baseTemperature, nightDrop, altitudeLapse, terrain, rain, occluders, heatSources, ambientFloor, temperatureAt })` → `EnvironmentField`. Sample **temperature**, **wetness**, **lightExposure** (direct sun/sky), and **ambientLight** (spawn gating) at any `(x, z, time)` — `sample(x, z, time, y?)` returns all four plus `sheltered`. Occluders (roofs/canopy) shade sun and shelter from rain; heat sources (campfires) warm nearby positions; `sunElevation(time)` drives the day cycle. Sun damages a vampire, cold forces campfires, low ambient light spawns mobs — the field answers "am I in sun vs. shade / cold vs. warm / dark vs. lit". Pure and instantaneous; stateful build-up belongs to a decay meter reading the field. +- **Weather → gameplay** (`world/weather`): `resolveWeather(state, table)` turns a `WeatherState { kind, intensity }` into concrete `ResolvedWeather` (`grip`, `visibility`, `structureDamage`, `chill`, `ignition`, `spread`) via a game-owned `WeatherModifierTable` — multipliers interpolate from neutral by intensity, rate effects scale linearly. Read `grip`/`visibility` in movement and AI, `structureDamage` on a building tick. +- **Fire spread** (`world/weather`): `createFireGrid({ cols, rows, cellSize, origin, fuelAt, spreadRate, burnRate, wind, windBias })` → `FireGrid` is a **coarse cellular** propagation (not a fluid solver): `ignite(x, z)` / `igniteCell(col, row)`, then `step(dt, { spread, wetnessAt })` transfers heat to neighbours biased by wind, consumes fuel (`unburnt → burning → burnt`), and honours firebreaks (zero-fuel cells) and rain/wetness suppression. `resolveWeather(...).spread` feeds the step; `@jgengine/shell/weather` `FireSpreadLayer` renders the burning/scorched cells. +- **Realm composition** (`world/realm`): `composeRealm(base, cards)` assembles a played instance at runtime from a deck of modifier **cards** (Nightingale realm cards) — a `major` card is the biome base, `minor` cards layer environment param overrides, a `WeatherState`, and spawn-table edits (`set`/`add`/`scale`/`remove`). The result recomposes both the environment (into a sampleable field via `composed.environmentField(extra?)`) and the `spawnTable`, and depends on the weather hooks above to turn its `weather` into gameplay modifiers. ### Survival meters, moodles & multi-region health -The `survival/` domain — decay-over-time meters and per-part health, both feeding one stacking **moodle** status display distinct from numeric bars. -- **Decay meters** (`survival/decayMeter`): `createDecayMeterSet([{ id, max, min?, start?, rate, thresholds }])` → `DecayMeterSet`. Each named meter (hunger, thirst, oxygen, sanity, warmth, stamina) drains/recovers on `tick(dt)` at `rate`, refills from consumables/actions via `refill(id, amount)`, and raises threshold moodles (`below`/`above`). `setRateModifier(id, mult)` lets the environment drive them — read an env field, then speed warmth loss when cold or oxygen loss in a toxic biome. -- **Moodles** (`survival/moodle`): the shared status stack, distinct from raw bars. `stackMoodles(...groups)` folds meter, ailment, and buff `Moodle[]` into one worst-first display (same-id stacks add, worst severity wins). `createMoodleStack()` holds timed buffs (`add({ id, label, duration })` — Valheim's concurrent food buffs) and expires them on `tick(dt)`. -- **Multi-region health** (`survival/regionHealth`): `createMultiRegionHealth({ regions, ailments })` → `MultiRegionHealth` gives per-part pools (head/thorax/arms/legs, Tarkov/DayZ style) — `damage(regionId, amount)` scales by `vulnerability` and kills when a `vital` part empties; a stacking **ailment queue** (`applyAilment`, `tick(dt)` drains like bleed) carries per-injury treatment (`treat(itemId)` clears wounds via bandage/tourniquet/splint). `ailmentMoodles()` shares the moodle display with the meters (#78 + #90). +The `survival/` domain — decay-over-time meters and per-part health, both feeding one stacking **moodle** status display distinct from numeric bars. +- **Decay meters** (`survival/decayMeter`): `createDecayMeterSet([{ id, max, min?, start?, rate, thresholds }])` → `DecayMeterSet`. Each named meter (hunger, thirst, oxygen, sanity, warmth, stamina) drains/recovers on `tick(dt)` at `rate`, refills from consumables/actions via `refill(id, amount)`, and raises threshold moodles (`below`/`above`). `setRateModifier(id, mult)` lets the environment drive them — read an env field, then speed warmth loss when cold or oxygen loss in a toxic biome. +- **Moodles** (`survival/moodle`): the shared status stack, distinct from raw bars. `stackMoodles(...groups)` folds meter, ailment, and buff `Moodle[]` into one worst-first display (same-id stacks add, worst severity wins). `createMoodleStack()` holds timed buffs (`add({ id, label, duration })` — Valheim's concurrent food buffs) and expires them on `tick(dt)`. +- **Multi-region health** (`survival/regionHealth`): `createMultiRegionHealth({ regions, ailments })` → `MultiRegionHealth` gives per-part pools (head/thorax/arms/legs, Tarkov/DayZ style) — `damage(regionId, amount)` scales by `vulnerability` and kills when a `vital` part empties; a stacking **ailment queue** (`applyAilment`, `tick(dt)` drains like bleed) carries per-injury treatment (`treat(itemId)` clears wounds via bandage/tourniquet/splint). `ailmentMoodles()` shares the moodle display with the meters (#78 + #90). ### Interactive building & terraform (renderer-free tools) Turn data-only placement into the build tooling of Valheim/Enshrouded/The Sims/Fortnite/Dinkum. All pure `@jgengine/core/world`; the shell renders the ghost/tint/brush (`shell/structures/PlacementGhost`, `shell/terrain/EditableGround`, `shell/terrain/TerraformBrushCursor`) driven by `pointer.worldHit()`. | Primitive | Answers | |-----------|---------| -| `createPlacementController({ footprint, rules, snapMode, grid })` | Owns the ghost: `hover(hit)` → `PlacementPreview` (`valid` tint wraps `validatePlacement`), `rotate()`, `setSnapMode`/`cycleSnapMode` (`"grid"`/`"free"`/`"surface"`), `commit()` → `PlacementCommit` (`rotationY` via `quarterTurnsToRotationY`). Feed it `pointer.worldHit()`. | -| `snapToNearest(registry, placed, movingDef, cursor, { snapDistance })` | Typed connector sockets — snaps a piece's socket onto the nearest **compatible** placed socket (`socketsCompatible` = both sides `accept` the other type). `worldSockets`/`socketWorldPosition` expand a piece's sockets to world space. | -| `solveSupport(pieces, links, { maxDistance })` → `SupportResult` | Walks the connector graph to any `grounded` piece: `supported` stays, `unsupported` collapses, `distance` (hops-to-ground) drives the white→red decay tint. `toDebrisBodies(pieces, unsupported)` → `AddBodyOptions[]` for the `PhysicsWorld` debris sink. | -| `createWallDrawTool({ snap, closeTolerance })` | Drag wall points → auto-encloses when the path returns to the start (`isEnclosed`), `footprint()` derives the room `EnclosedFootprint`, `roof()` auto-fits a hip/gable/flat `RoofPlan`. `createSurfacePaint()` stores per-tile floor/wall surfaces. | -| `createPlacedStructureStore()` | Save/load a built layout: `add`/`move`/`rotate`/`remove`/`select`, `snapshot()`↔`load()` round-trip (survives reload), `subscribe` for the renderer. | -| `createEditableTerrain({ bounds, base, cellSize })` → `EditableTerrain` | A `TerrainField` you can **write back to**: `apply(edit: TerraformEdit)` sculpts `raise`/`lower`/`smooth`/`flatten`/`noise`/`ramp` (plus surface `paint`) under a cursor and re-samples `sampleHeight`; brush `shape`/`heightLimit`/`falloff` shape the footprint; `surfaceAt`, `snapshot`/`restore`, `reset`. `createTerraformBrush(field)` is the simple cursor tool (radius/strength). This write-back grid is the shared terrain-edit pattern. | +| `createPlacementController({ footprint, rules, snapMode, grid })` | Owns the ghost: `hover(hit)` → `PlacementPreview` (`valid` tint wraps `validatePlacement`), `rotate()`, `setSnapMode`/`cycleSnapMode` (`"grid"`/`"free"`/`"surface"`), `commit()` → `PlacementCommit` (`rotationY` via `quarterTurnsToRotationY`). Feed it `pointer.worldHit()`. | +| `snapToNearest(registry, placed, movingDef, cursor, { snapDistance })` | Typed connector sockets — snaps a piece's socket onto the nearest **compatible** placed socket (`socketsCompatible` = both sides `accept` the other type). `worldSockets`/`socketWorldPosition` expand a piece's sockets to world space. | +| `solveSupport(pieces, links, { maxDistance })` → `SupportResult` | Walks the connector graph to any `grounded` piece: `supported` stays, `unsupported` collapses, `distance` (hops-to-ground) drives the white→red decay tint. `toDebrisBodies(pieces, unsupported)` → `AddBodyOptions[]` for the `PhysicsWorld` debris sink. | +| `createWallDrawTool({ snap, closeTolerance })` | Drag wall points → auto-encloses when the path returns to the start (`isEnclosed`), `footprint()` derives the room `EnclosedFootprint`, `roof()` auto-fits a hip/gable/flat `RoofPlan`. `createSurfacePaint()` stores per-tile floor/wall surfaces. | +| `createPlacedStructureStore()` | Save/load a built layout: `add`/`move`/`rotate`/`remove`/`select`, `snapshot()`↔`load()` round-trip (survives reload), `subscribe` for the renderer. | +| `createEditableTerrain({ bounds, base, cellSize })` → `EditableTerrain` | A `TerrainField` you can **write back to**: `apply(edit: TerraformEdit)` sculpts `raise`/`lower`/`smooth`/`flatten`/`noise`/`ramp` (plus surface `paint`) under a cursor and re-samples `sampleHeight`; brush `shape`/`heightLimit`/`falloff` shape the footprint; `surfaceAt`, `snapshot`/`restore`, `reset`. `createTerraformBrush(field)` is the simple cursor tool (radius/strength). This write-back grid is the shared terrain-edit pattern. | | Compact sculpt strokes | `beginTerraformStroke(terrain)` batches a whole drag of stamps into one `TerraformDelta` (`.stamp(edit)` → `.delta()`) so undo replays a stroke as a single step without copying the heightfield. `editDelta`/`applyDelta`/`revertDelta` do the same for one edit. Serialize with `createTerrainSnapshot({ bounds, cellSize })` and rebuild via `editableTerrainFromSnapshot(snapshot, base)`; `applyDeltaToSnapshot`/`revertDeltaFromSnapshot` redo/undo a delta onto a stored snapshot copy-on-write. Backs the editor's Terrain tool (`jgengine-editor`). | | Material paint strokes | The same delta model for surface layers: `terrain.paintDelta(edit)` / `beginSurfaceStroke(terrain)` record a `SurfaceDelta`; `fillSurfaceDelta(surface)` fills or clears (`null`) every cell; `autoPaintDelta({ surface, minSlope, maxSlope, minHeight, maxHeight })` paints by a slope/height rule (rock on steep, snow up high). `applySurfaceDelta`/`revertSurfaceDelta` on the live field, `applySurfaceDeltaToSnapshot`/`revertSurfaceDeltaFromSnapshot` copy-on-write on a stored snapshot. `surfaceAt(x,z)` reads the painted id back for coloring. | | `createPlotPermissions({ plotId, ownerId, guildId? })` + `createContributionPool(goal)` | Per-plot/guild edit authority (`canEdit`/`canView`, `grant`/`revoke` `BuildRole`, guild inheritance) for co-op building, plus a pooled-resource contribution model (`contribute` caps at the goal, reports overflow, `isComplete`, per-contributor totals). | @@ -76,38 +76,38 @@ Turn data-only placement into the build tooling of Valheim/Enshrouded/The Sims/F ### Physics world (optional, headless) -`physics/physicsWorld` `PhysicsWorld` is a standalone fixed-capacity rigid-body sim (SoA buffers, spatial-hash broadphase, sleeping) — **not** the `defineGame` `physics: { gravity, jumpVelocity }` field, which the built-in walk controller reads directly every frame (see "Controller kinematics" above; both values are real and honored, not dead config). Reach for `PhysicsWorld` when a game needs many colliding dynamic bodies (piles, debris, stress scenes): `new PhysicsWorld({ capacity, bounds, … })`, `addBody({ position, mass?, ...shape })`, then `step(dt)` per tick → `PhysicsStats`. Core owns the sim; `@jgengine/shell/world/InstancedBodies` renders its bodies. Most games never need it — the character controller covers ordinary movement. The broadphase grid (`nx*ny*nz` cells from `bounds`/`cellSize`) throws at construction if it would exceed a sane cell cap — shrink `bounds` or raise `cellSize` (same guard on `physics/spatialGrid`'s `SpatialGrid`). +`physics/physicsWorld` `PhysicsWorld` is a standalone fixed-capacity rigid-body sim (SoA buffers, spatial-hash broadphase, sleeping) — **not** the `defineGame` `physics: { gravity, jumpVelocity }` field, which the built-in walk controller reads directly every frame (see "Controller kinematics" above; both values are real and honored, not dead config). Reach for `PhysicsWorld` when a game needs many colliding dynamic bodies (piles, debris, stress scenes): `new PhysicsWorld({ capacity, bounds, … })`, `addBody({ position, mass?, ...shape })`, then `step(dt)` per tick → `PhysicsStats`. Core owns the sim; `@jgengine/shell/world/InstancedBodies` renders its bodies. Most games never need it — the character controller covers ordinary movement. The broadphase grid (`nx*ny*nz` cells from `bounds`/`cellSize`) throws at construction if it would exceed a sane cell cap — shrink `bounds` or raise `cellSize` (same guard on `physics/spatialGrid`'s `SpatialGrid`). -**Body shape: box or sphere.** `AddBodyOptions` is a discriminated union — `{ shape?: "box", halfExtents }` (box is the default, `shape` omittable) or `{ shape: "sphere", radius }` (the radius fills all three half-extent columns, so broadphase/bounds see the sphere's enclosing AABB). `world.shape[i]` (`SHAPE_BOX` / `SHAPE_SPHERE`) reports a live body's shape for a consumer walking the raw SoA arrays. Sphere-sphere and sphere-box pairs resolve with a proper radial normal (not the axis-aligned box/box path) — balls, projectile bodies, and rolling debris collide correctly against both boxes and each other. +**Body shape: box or sphere.** `AddBodyOptions` is a discriminated union — `{ shape?: "box", halfExtents }` (box is the default, `shape` omittable) or `{ shape: "sphere", radius }` (the radius fills all three half-extent columns, so broadphase/bounds see the sphere's enclosing AABB). `world.shape[i]` (`SHAPE_BOX` / `SHAPE_SPHERE`) reports a live body's shape for a consumer walking the raw SoA arrays. Sphere-sphere and sphere-box pairs resolve with a proper radial normal (not the axis-aligned box/box path) — balls, projectile bodies, and rolling debris collide correctly against both boxes and each other. -**Collision shapes are box/sphere/voxel only.** Every collider in the engine — `PhysicsWorld` bodies (box `halfExtents` or sphere `radius`), object/entity picking (`scene/objectQuery` raycasts against unit boxes), and `world/voxelField`/`world/carve` blocks — is an axis-aligned box, a sphere, or a voxel cell. Arbitrary authored level-mesh collision (a sculpted GLB as a collider) is not supported. The seams for custom collision are `movement.beforeCommit` (steer or replace the walk controller's resolved step) and object raycasts (query the scene yourself and react) — not a mesh collider. +**Collision shapes are box/sphere/voxel only.** Every collider in the engine — `PhysicsWorld` bodies (box `halfExtents` or sphere `radius`), object/entity picking (`scene/objectQuery` raycasts against unit boxes), and `world/voxelField`/`world/carve` blocks — is an axis-aligned box, a sphere, or a voxel cell. Arbitrary authored level-mesh collision (a sculpted GLB as a collider) is not supported. The seams for custom collision are `movement.beforeCommit` (steer or replace the walk controller's resolved step) and object raycasts (query the scene yourself and react) — not a mesh collider. -**Ballistic collision sweep (`physics/ballisticSweep`).** `createBallisticSweep(world, { step?, radius? })` → `BallisticSweep`, a `(origin, velocity, gravity, maxTime) => BallisticSweepHit | null` function that marches the closed-form arc (constant gravity, straight lateral) through a `PhysicsWorld` and reports the first sample inside any live body's AABB (sleeping bodies included), refined by one bisection step; `null` means the whole arc is clear. `step` (default 1/60) is the march interval in seconds, `radius` (default 0) inflates every body's AABB before the point test — pass the projectile's own radius. Wire it into `combat/projectiles` via `ProjectileSystemDeps.sweepBallistic`: when set, a ballistic shot settles at the swept impact point instead of the closed-form landing; omitted or `null` falls back to that closed-form arc. +**Ballistic collision sweep (`physics/ballisticSweep`).** `createBallisticSweep(world, { step?, radius? })` → `BallisticSweep`, a `(origin, velocity, gravity, maxTime) => BallisticSweepHit | null` function that marches the closed-form arc (constant gravity, straight lateral) through a `PhysicsWorld` and reports the first sample inside any live body's AABB (sleeping bodies included), refined by one bisection step; `null` means the whole arc is clear. `step` (default 1/60) is the march interval in seconds, `radius` (default 0) inflates every body's AABB before the point test — pass the projectile's own radius. Wire it into `combat/projectiles` via `ProjectileSystemDeps.sweepBallistic`: when set, a ballistic shot settles at the swept impact point instead of the closed-form landing; omitted or `null` falls back to that closed-form arc. -**Removing and moving bodies.** `removeBody(id)` tombstones a body — it drops out of integration/broadphase and its slot is queued for the next `addBody` — without moving or invalidating any other body's `id` (ids are raw SoA slots, stored as-is in joints and game state, so nothing ever gets swapped). It conservatively wakes any sleeping body whose AABB touched the removed one's (no persistent contact set to consult, so this errs toward waking too much, never too little). `setVelocity(id, x, y, z)` and `setPosition(id, x, y, z)` write a body's velocity/position directly (instead of poking the public `velX`/`posX` SoA arrays) and wake it if asleep; `teleport(id, x, y, z)` is `setPosition` plus a hard velocity reset (respawn/teleporter, vs. sliding). `isAlive(id)`/`highWater` (one past the highest slot ever handed out) let a consumer that iterates the raw SoA arrays skip tombstoned holes correctly instead of assuming `count` is a dense `0..count` range. +**Removing and moving bodies.** `removeBody(id)` tombstones a body — it drops out of integration/broadphase and its slot is queued for the next `addBody` — without moving or invalidating any other body's `id` (ids are raw SoA slots, stored as-is in joints and game state, so nothing ever gets swapped). It conservatively wakes any sleeping body whose AABB touched the removed one's (no persistent contact set to consult, so this errs toward waking too much, never too little). `setVelocity(id, x, y, z)` and `setPosition(id, x, y, z)` write a body's velocity/position directly (instead of poking the public `velX`/`posX` SoA arrays) and wake it if asleep; `teleport(id, x, y, z)` is `setPosition` plus a hard velocity reset (respawn/teleporter, vs. sliding). `isAlive(id)`/`highWater` (one past the highest slot ever handed out) let a consumer that iterates the raw SoA arrays skip tombstoned holes correctly instead of assuming `count` is a dense `0..count` range. -**Joints & constraints.** `hingeJoint`/`fixedJoint`/`distanceJoint`/`springJoint(opts)` connect two bodies, or a body to a fixed world point (omit `bodyB`). The sim is translational (no angular DOF), so `hinge`/`fixed` pin the shared anchor (the `axis` is retained metadata), `distance` holds a fixed separation, and `spring` drives toward `restLength` with `stiffness`/`damping` (suspension, follow-point carry). `removeJoint(id)`, `setJointAnchor(id, x, y, z)` (move anchor B — a world anchor's follow point, or body B's local offset), `setJointAnchorA(id, x, y, z)` (move anchor A's local offset — e.g. re-rotating a suspension mount each frame as the chassis turns), `setJointRest`, and `readJointSegments(out)` for `@jgengine/shell/world/InstancedJoints` (debug line render). This is the foundation under vehicles, ragdolls, grapples, and carry. +**Joints & constraints.** `hingeJoint`/`fixedJoint`/`distanceJoint`/`springJoint(opts)` connect two bodies, or a body to a fixed world point (omit `bodyB`). The sim is translational (no angular DOF), so `hinge`/`fixed` pin the shared anchor (the `axis` is retained metadata), `distance` holds a fixed separation, and `spring` drives toward `restLength` with `stiffness`/`damping` (suspension, follow-point carry). `removeJoint(id)`, `setJointAnchor(id, x, y, z)` (move anchor B — a world anchor's follow point, or body B's local offset), `setJointAnchorA(id, x, y, z)` (move anchor A's local offset — e.g. re-rotating a suspension mount each frame as the chassis turns), `setJointRest`, and `readJointSegments(out)` for `@jgengine/shell/world/InstancedJoints` (debug line render). This is the foundation under vehicles, ragdolls, grapples, and carry. -**Collision → gameplay events.** `world.onCollision(listener, minApproachSpeed?)` delivers every impacting contact — `CollisionEvent { a, b, nx, ny, nz, approachSpeed, impulse }` — to game code during `step` (the object is reused; read/copy it, never retain). This is the seam crash-damage and destruction read; pass `null` to detach. +**Collision → gameplay events.** `world.onCollision(listener, minApproachSpeed?)` delivers every impacting contact — `CollisionEvent { a, b, nx, ny, nz, approachSpeed, impulse }` — to game code during `step` (the object is reused; read/copy it, never retain). This is the seam crash-damage and destruction read; pass `null` to detach. -**Actors on top of the sim:** `physics/ragdoll` (`createRagdoll(world, { bones, links, balance? })` — jointed bones, floppy or active-ragdoll via a balance motor), `physics/carryable` (`Carryable` — grab a body to a follow point, shared multi-owner carry, `carrySpeedMultiplier` encumbrance, drop/throw; the raycast pick is the caller's job, core owns the constraint), `physics/forceVolume` (`ForceVolume` — impulse/velocity/accelerate trigger region, `once` for boost pads; `PlatformCarry` — carry bodies standing on a moving platform by its per-`step` delta). Separately, `physics/spatialGrid` `SpatialGrid` is a broad-phase grid over the x/z plane, **distinct** from the rigid-body sim, for cheap same-tick proximity across hundreds–thousands of simple movers — `rebuild(count, xs, zs)` then `queryCircle` (swarm enemies hitting a player/AoE) or `forEachPair` (mutual separation). +**Actors on top of the sim:** `physics/ragdoll` (`createRagdoll(world, { bones, links, balance? })` — jointed bones, floppy or active-ragdoll via a balance motor), `physics/carryable` (`Carryable` — grab a body to a follow point, shared multi-owner carry, `carrySpeedMultiplier` encumbrance, drop/throw; the raycast pick is the caller's job, core owns the constraint), `physics/forceVolume` (`ForceVolume` — impulse/velocity/accelerate trigger region, `once` for boost pads; `PlatformCarry` — carry bodies standing on a moving platform by its per-`step` delta). Separately, `physics/spatialGrid` `SpatialGrid` is a broad-phase grid over the x/z plane, **distinct** from the rigid-body sim, for cheap same-tick proximity across hundreds–thousands of simple movers — `rebuild(count, xs, zs)` then `queryCircle` (swarm enemies hitting a player/AoE) or `forEachPair` (mutual separation). -**Swarm-scale presentation.** `@jgengine/shell/world/SpriteBatch` renders up to `capacity` sprite-sheet quads as one `InstancedMesh` (per-instance atlas `frame`, `plane: "xy" | "xz"` or `billboard`, `pixelated` nearest-filtering) — the batched path for bullet-heaven swarms, item streams, and tile-grid presentation instead of one `` per entity. Pair it with `world/lod`'s `createLodScheduler` (see the concept table) to band render detail by distance and throttle far entities' update work. +**Swarm-scale presentation.** `@jgengine/shell/world/SpriteBatch` renders up to `capacity` sprite-sheet quads as one `InstancedMesh` (per-instance atlas `frame`, `plane: "xy" | "xz"` or `billboard`, `pixelated` nearest-filtering) — the batched path for bullet-heaven swarms, item streams, and tile-grid presentation instead of one `` per entity. Pair it with `world/lod`'s `createLodScheduler` (see the concept table) to band render detail by distance and throttle far entities' update work. -**Traversal (`physics/traversal`).** `Grapple` fires a rope from a body to a fixed world point on the joint API — `fire(x,y,z)` attaches a `distance` (rigid) or `elastic` (spring) joint, `reel(dt)`/`payOut(dt)` shorten/lengthen the rope to pull the traveller in, `moveAnchor` re-points it (ziplines, grapple-to-moving-target). Grapple/zipline/swing (Sekiro, Deep Rock, Just Cause) are all the same primitive; the raycast that finds the anchor is the caller's. `Glide` is a reduced-gravity, forward-thrust wingsuit/glider over a body — call `apply(dt, steerX, steerZ)` each frame before `step` to feed back most of gravity (`gravityScale`), thrust along the steer vector, and clamp descent; stop calling it to fall normally, no attach/detach state. -**Structural destruction (`physics/structure`).** `StructureGraph` models a building as nodes (pieces) + load-bearing edges with some nodes `anchor`ed (foundations). `damage(id, n)`/`damageEdge(a,b,n)`/`severEdge(a,b)` wear pieces and connections; when one breaks, the graph recomputes reachability to an anchor and returns a single `CollapseEvent { fell }` — every piece the loss disconnected. `toDebris(world, event)` sinks the fallen pieces into a `PhysicsWorld` as rigid bodies (The Finals, Rainbow Six). It is coarse by design: **replicate the collapse event (the `fell` id list), not each fragment's physics** — game clients re-derive the debris locally. Piece integrity and edge strength default from a `StructureMaterial` table (DATA). +**Traversal (`physics/traversal`).** `Grapple` fires a rope from a body to a fixed world point on the joint API — `fire(x,y,z)` attaches a `distance` (rigid) or `elastic` (spring) joint, `reel(dt)`/`payOut(dt)` shorten/lengthen the rope to pull the traveller in, `moveAnchor` re-points it (ziplines, grapple-to-moving-target). Grapple/zipline/swing (Sekiro, Deep Rock, Just Cause) are all the same primitive; the raycast that finds the anchor is the caller's. `Glide` is a reduced-gravity, forward-thrust wingsuit/glider over a body — call `apply(dt, steerX, steerZ)` each frame before `step` to feed back most of gravity (`gravityScale`), thrust along the steer vector, and clamp descent; stop calling it to fall normally, no attach/detach state. +**Structural destruction (`physics/structure`).** `StructureGraph` models a building as nodes (pieces) + load-bearing edges with some nodes `anchor`ed (foundations). `damage(id, n)`/`damageEdge(a,b,n)`/`severEdge(a,b)` wear pieces and connections; when one breaks, the graph recomputes reachability to an anchor and returns a single `CollapseEvent { fell }` — every piece the loss disconnected. `toDebris(world, event)` sinks the fallen pieces into a `PhysicsWorld` as rigid bodies (The Finals, Rainbow Six). It is coarse by design: **replicate the collapse event (the `fell` id list), not each fragment's physics** — game clients re-derive the debris locally. Piece integrity and edge strength default from a `StructureMaterial` table (DATA). ### Vehicles, mounts, crash damage & racing -Five primitives layer a driving/racing game over the physics sim and `world/water`. All are **data-first** (spec the chassis/wheels/grip curve, damage thresholds, and checkpoint layout as catalog data) and pure `@jgengine/core`; renderers live in the game/shell. Each `update(dt, …)` runs **before** the shared `world.step(dt)`. -- **Analog input — `input/axisInput`.** `AxisInput { throttle, brake, steer, handbrake }` is a continuous channel, **distinct from the digital action bindings**. `new AxisChannel({ bindings, smoothing })` ramps held keys into pedal-like analog values (`sample(dt, isDown)`), or `setAnalog(axis, value)` drives it straight from a gamepad axis. `DRIVE_AXIS_BINDINGS` is a ready WASD/arrow map. `sampleAxisBindings` is the instantaneous, unsmoothed read the channel ramps toward each frame — exposed headlessly as `ctx.input.axis(bindings, ranges?)`, bound to **action names** rather than raw key codes (#533.7). -- **`physics/vehicleBody`.** `createVehicleBody(world, config)` is an arcade car: a chassis box body with per-wheel suspension held by G3's `springJoint` against the sampled `groundHeight`, drive/brake along the heading, and a `GripCurve` (`sampleGripCurve`) that bleeds lateral velocity for cornering — and, under `handbrake`, drift. `update(dt, axisInput)` then `world.step`. Because the chassis is a real body it still collides, which feeds crash damage. Rocket League, Trackmania, Wreckfest. -- **`physics/kinematicVehicle`.** `createKinematicVehicle(tuning, options?)` is the no-`PhysicsWorld` sibling of `vehicleBody` — same steer-yaw/accel/grip-curve arcade feel, driven purely by `tick(dt, axis, modifiers?)` returning `{ position, heading, forwardSpeed, lateralSpeed, slip, surface }`. `surfaceFriction`/`dragAt` hooks (grip-by-position, drag zones) and `clampMove` (walls/arena bounds) keep game flavor out of the sim; `scaleVelocity`/`resetTo` cover boost pads and respawns. Racers and open-world cars that don't need real rigid-body collision. -- **`physics/drivableVehicle`** (#533.1). `tickDrivableVehicle(vehicle, dt, axis, { groundHeight?, modifiers? })` is the missing wire from an `AxisInput` sample through a `KinematicVehicle` to a `setPose`-ready entity patch — the throttle/steer/handbrake → sim → `entities.setPose(id, drive.pose)` loop every drivable-vehicle game hand-rolled, one call instead of a hand-copied per-game `stepCar`. `groundHeight` snaps the resulting `y` to terrain each tick; omit it to keep the sim's flat `y`. Returns `{ pose, step }` — `step` is the raw `KinematicVehicleStep` for HUD speed/slip reads. -- **`physics/buoyancy`.** `createBuoyantBody(world, { body, water, … })` floats a body on a CPU `WaterSurface` (Archimedes per hull point + water drag) so it settles at the waterline and rides the Gerstner waves; pass an `AxisInput` to `update(dt, time, input?)` and it drives as a boat (thrust + yaw + keel). Sea of Thieves, BOTW rafts. -- **`scene/mount`.** `createMountController()` transfers control to a driven entity: `register({ id, kit, seats })`, `mount(riderId, mountId, seatId?)`, `dismount`. Read `cameraTarget(riderId)` to point the follow camera and `driveTarget(riderId)` to route that rider's `AxisInput` at the mount — the control seat drives, passenger seats ride (multi-seat shared vehicles), and an un-mounted rider drives themselves. `driver(mountId)`/`occupants(mountId)`/`kitOf`. Palworld mounts, V Rising horse, a crewed ship. +Five primitives layer a driving/racing game over the physics sim and `world/water`. All are **data-first** (spec the chassis/wheels/grip curve, damage thresholds, and checkpoint layout as catalog data) and pure `@jgengine/core`; renderers live in the game/shell. Each `update(dt, …)` runs **before** the shared `world.step(dt)`. +- **Analog input — `input/axisInput`.** `AxisInput { throttle, brake, steer, handbrake }` is a continuous channel, **distinct from the digital action bindings**. `new AxisChannel({ bindings, smoothing })` ramps held keys into pedal-like analog values (`sample(dt, isDown)`), or `setAnalog(axis, value)` drives it straight from a gamepad axis. `DRIVE_AXIS_BINDINGS` is a ready WASD/arrow map. `sampleAxisBindings` is the instantaneous, unsmoothed read the channel ramps toward each frame — exposed headlessly as `ctx.input.axis(bindings, ranges?)`, bound to **action names** rather than raw key codes (#533.7). +- **`physics/vehicleBody`.** `createVehicleBody(world, config)` is an arcade car: a chassis box body with per-wheel suspension held by G3's `springJoint` against the sampled `groundHeight`, drive/brake along the heading, and a `GripCurve` (`sampleGripCurve`) that bleeds lateral velocity for cornering — and, under `handbrake`, drift. `update(dt, axisInput)` then `world.step`. Because the chassis is a real body it still collides, which feeds crash damage. Rocket League, Trackmania, Wreckfest. +- **`physics/kinematicVehicle`.** `createKinematicVehicle(tuning, options?)` is the no-`PhysicsWorld` sibling of `vehicleBody` — same steer-yaw/accel/grip-curve arcade feel, driven purely by `tick(dt, axis, modifiers?)` returning `{ position, heading, forwardSpeed, lateralSpeed, slip, surface }`. `surfaceFriction`/`dragAt` hooks (grip-by-position, drag zones) and `clampMove` (walls/arena bounds) keep game flavor out of the sim; `scaleVelocity`/`resetTo` cover boost pads and respawns. Racers and open-world cars that don't need real rigid-body collision. +- **`physics/drivableVehicle`** (#533.1). `tickDrivableVehicle(vehicle, dt, axis, { groundHeight?, modifiers? })` is the missing wire from an `AxisInput` sample through a `KinematicVehicle` to a `setPose`-ready entity patch — the throttle/steer/handbrake → sim → `entities.setPose(id, drive.pose)` loop every drivable-vehicle game hand-rolled, one call instead of a hand-copied per-game `stepCar`. `groundHeight` snaps the resulting `y` to terrain each tick; omit it to keep the sim's flat `y`. Returns `{ pose, step }` — `step` is the raw `KinematicVehicleStep` for HUD speed/slip reads. +- **`physics/buoyancy`.** `createBuoyantBody(world, { body, water, … })` floats a body on a CPU `WaterSurface` (Archimedes per hull point + water drag) so it settles at the waterline and rides the Gerstner waves; pass an `AxisInput` to `update(dt, time, input?)` and it drives as a boat (thrust + yaw + keel). Sea of Thieves, BOTW rafts. +- **`scene/mount`.** `createMountController()` transfers control to a driven entity: `register({ id, kit, seats })`, `mount(riderId, mountId, seatId?)`, `dismount`. Read `cameraTarget(riderId)` to point the follow camera and `driveTarget(riderId)` to route that rider's `AxisInput` at the mount — the control seat drives, passenger seats ride (multi-seat shared vehicles), and an un-mounted rider drives themselves. `driver(mountId)`/`occupants(mountId)`/`kitOf`. Palworld mounts, V Rising horse, a crewed ship. - **`scene/stationClaim`.** `createStationClaim(controller?)` layers **facet stations** on `scene/mount` for a vehicle several players crew at once: `register({ id, kit, stations })` where each `Station` tags a seat with a `facet` (`"steer"`/`"sails"`/`"cannon"`). `claim(playerId, vehicleId, facetOrStationId)`, `release`, `controllerOf(vehicleId, facet)` (who mans it), `facetOf(playerId)`, `openFacets`, `crew`. Only a `control` station operates the hull (`driver`/`driveTarget`); the rest ride but command their own facet. Sea of Thieves helm + sails + cannons. - **`scene/vehicleSeat`** (#533.2). `createVehicleSeats(controller?)` composes `scene/mount`'s control-transfer with the enter/exit-vehicle transition every game hand-rolled: `register({ id, kit, seats? })` then `enter(riderId, vehicleId, options?)` resolves a free seat and returns `{ seat, cameraTarget, driveTarget, riderMovementPatch }` in one call — feed `cameraTarget` into `ctx.camera.follow(...)`, `riderMovementPatch` (`{ frozen: true }`) into `entities.update(riderId, { movement: { ...current, frozen } })`. `exit(riderId, vehiclePose, offset?)` computes a side-door placement (`distance`/`side: "left" | "right"`) next to the vehicle and returns the same triad in reverse (`riderMovementPatch: { frozen: false }`, `cameraTarget` back to the rider). Pure — no entity/camera side effects, the caller applies the patches via its own `ctx`. `isSeated`/`driverOf`; `.mounts` exposes the underlying `MountController` for direct dismount (e.g. a vehicle explosion that skips the placement math). GTA-style boarding, mountable creatures, boat docking. - **`physics/damageZones`.** `createDamageModel({ zones, disableAt })` maps accumulated contact impulse (from `onCollision`) to **coarse discrete stages** (not soft-body): `absorb(zoneId, impulse)` / `routeCollision(event, resolveZone)` bump a zone's stage (caller swaps the visual/collider), an optional `detachStage` ejects a part as debris once, and crossing `disableAt` flips a whole-vehicle `disabled` state. Wreckfest crumple/derby. -- **`game/race`.** `raceTrack({ checkpoints, laps })` is an ordered ring of AABB checkpoint volumes (the final one is the finish line); `createRaceState({ track, win })` — driven each tick by `update(now, positions)` on game time — emits `checkpoint.hit` / `lap.completed` / `position.changed` / `race.finished`, keeps split times, resolves a pluggable `RaceWinCondition` (`firstPastPost`, `topK` round-cut, `everyoneFinishes`, `lastStanding` derby), and `resetToCheckpoint(id)` hands back a respawn pose. `removeRacer(id)` drops a racer mid-race and renumbers the remaining standings; `reset()` clears all racer progress/finish state back to construction time so the same instance replays without rebuilding it. Trackmania, Mario Kart, Fall Guys. +- **`game/race`.** `raceTrack({ checkpoints, laps })` is an ordered ring of AABB checkpoint volumes (the final one is the finish line); `createRaceState({ track, win })` — driven each tick by `update(now, positions)` on game time — emits `checkpoint.hit` / `lap.completed` / `position.changed` / `race.finished`, keeps split times, resolves a pluggable `RaceWinCondition` (`firstPastPost`, `topK` round-cut, `everyoneFinishes`, `lastStanding` derby), and `resetToCheckpoint(id)` hands back a respawn pose. `removeRacer(id)` drops a racer mid-race and renumbers the remaining standings; `reset()` clears all racer progress/finish state back to construction time so the same instance replays without rebuilding it. Trackmania, Mario Kart, Fall Guys. - **Session lifecycle** (`RaceSessionState`, phases `idle → countdown → racing → finished`) — the pure clock every racer wraps around `RaceState`, so no game hand-rolls its own `RacePhase`/`COUNTDOWN_SECONDS`. `idleRaceSession()` is the grid; `startRaceCountdown({ seconds })` drops the lights (default 3, `seconds ≤ 0` = standing start); `tickRaceSession(session, dt)` bleeds the countdown then accumulates `elapsed` once green; `finishRaceSession(session)` freezes the clock at the flag. It carries no track/lap data — compose it with `RaceState` (positions/laps) and `createLapTimer` (per-lap wall-clock). A racing game layers medals/DNF on top of it. ```ts let session = startRaceCountdown(); // { phase: "countdown", countdown: 3, elapsed: 0 } @@ -133,10 +133,10 @@ ctx.scene.entity.bind("racers").sync( ### Spawn placement -`spawn(catalogId, { id?, position | anchor, offset?, parentSpace?, group? })` — anchor `{ kind: "entity" | "zone", id }` with offset `{ radius, pattern }` or `{ xyz }`. Catalog supplies movement/model; no behaviors on spawn. +`spawn(catalogId, { id?, position | anchor, offset?, parentSpace?, group? })` — anchor `{ kind: "entity" | "zone", id }` with offset `{ radius, pattern }` or `{ xyz }`. Catalog supplies movement/model; no behaviors on spawn. -**Named spawn/respawn points** (`game/spawnPoints`) — `createSpawnPoints()`: `record(id, { x, y, z, rotationY? })` names a point (level bounds, team spawns, checkpoints), `get(id)`/`list()` read them back, `respawn(entities, entityId, spawnId)` teleports an existing entity to a recorded point via `setPose` in one call — the id-keyed alternative to threading raw coordinates through respawn logic by hand. +**Named spawn/respawn points** (`game/spawnPoints`) — `createSpawnPoints()`: `record(id, { x, y, z, rotationY? })` names a point (level bounds, team spawns, checkpoints), `get(id)`/`list()` read them back, `respawn(entities, entityId, spawnId)` teleports an existing entity to a recorded point via `setPose` in one call — the id-keyed alternative to threading raw coordinates through respawn logic by hand. -**Level sequence** (`game/levelSequence`) — `createLevelSequence({ levels: [{ id, config }], retriesPerLevel? })` is a pure, deterministic campaign machine: `start()` enters level 0, `clear()` marks the current level cleared, `advance()` moves to the next (or `"complete"` after the last), `fail()` consumes an attempt and returns `"retry"` while `retriesPerLevel` remain else `"failed"`, `retry()` restarts after a retry-eligible failure. `current()` → `{ id, index, config, attempt } | null`; `progress()` → `{ index, total, cleared }`; `reset()` rewinds to idle. Mirrors the reducer style of `game/race` and `ai/spawnDirector` — a level-select/roguelike-run campaign shell without hand-rolling the state machine per game. +**Level sequence** (`game/levelSequence`) — `createLevelSequence({ levels: [{ id, config }], retriesPerLevel? })` is a pure, deterministic campaign machine: `start()` enters level 0, `clear()` marks the current level cleared, `advance()` moves to the next (or `"complete"` after the last), `fail()` consumes an attempt and returns `"retry"` while `retriesPerLevel` remain else `"failed"`, `retry()` restarts after a retry-eligible failure. `current()` → `{ id, index, config, attempt } | null`; `progress()` → `{ index, total, cleared }`; `reset()` rewinds to idle. Mirrors the reducer style of `game/race` and `ai/spawnDirector` — a level-select/roguelike-run campaign shell without hand-rolling the state machine per game. diff --git a/.claude/skills/jgengine/SKILL.md b/.claude/skills/jgengine/SKILL.md index 95e561adc..1f8a19996 100644 --- a/.claude/skills/jgengine/SKILL.md +++ b/.claude/skills/jgengine/SKILL.md @@ -38,8 +38,7 @@ This skill is the foundation for every task (packages, project shape, defineGame | Need | Read | | --- | --- | -| Terrain, scenes, camera, movement, physics, maps, sensors | `jgengine-world` | -| Seeded generation, terrain/environment generation, grids, buildings, simulation | `jgengine-procedural` | +| Terrain, scenes, camera, movement, physics, maps, sensors, seeded/environment generation, grids, buildings | `jgengine-world` | | Damage, effects, weapons, targeting, projectiles, loot, death | `jgengine-combat` | | Items, quests, dialogue, economy, crafting, objectives, turns, social systems | `jgengine-gameplay` | | Networking adapters, authority, rooms/topology, persistence/backend seams | `jgengine-multiplayer` | @@ -444,7 +443,7 @@ src/ **Opt-in `ctx.game.*` subsystems (`features`)** — core is genre-agnostic: the always-on base is `commands` / `events` / `store` / `feed` / `loot` / `economy` (plus `audio`), and genre subsystems are opt-in via `defineGame({ features: { quest, trade, unlocks, cosmetics, roster, cards, turn, race, leaderboard, social, chat } })`. Omit one and `ctx.game.` is `undefined` (`cosmetics` hangs off `ctx.player`) — a puzzle game isn't handed a quest journal, a shop, a card pile, or party/chat it never asked for. Declare only what the game uses (`chat` implies `social`). -**Offline whole-world save (`persist`)** — `defineGame({ persist: true })` binds `ctx.game.save` for a single-player/offline game (auto-wired only when `isOffline(multiplayer)` — never for a host-authoritative world). It autosaves the *entire* world to `localStorage` through the same `ctx.snapshot()`/`ctx.hydrate()` seam multiplayer replicates over — every `defineStore` slot, all entities/stats/inventories — no per-field code. `createRuntimeSave` is the underlying bridge (any `{ snapshot, hydrate, subscribe }` target + a swappable `SaveBackend`). Modes/save-points/slots and cloud-swap: `jgengine-gameplay` → "Save the *whole* game automatically". +**Offline whole-world save (`persist`)** — `defineGame({ persist: true })` binds `ctx.game.save` for an offline game and autosaves the entire world with no per-field code. Full authoring guide (modes, save points, slots, cloud swap): `jgengine-gameplay` → "Save the *whole* game automatically". ```ts // game.config.ts — imports only, nothing inline diff --git a/.claude/skills/jgengine/api.md b/.claude/skills/jgengine/api.md index 52b553959..8d69a69dd 100644 --- a/.claude/skills/jgengine/api.md +++ b/.claude/skills/jgengine/api.md @@ -44,6 +44,8 @@ ## @jgengine/core/commands/commandRegistry +- `CommandDecodeResult` (type): type CommandDecodeResult = | { ok: true; value: TInput } | { ok: false; reason: string } — ⚠ undocumented +- `CommandDecoder` (type): type CommandDecoder = (input: unknown) => CommandDecodeResult — Parses raw `unknown` transport input into `TInput`, rejecting anything that doesn't match the command's declared shape. Runs before `validate`/`apply`, so a malformed payload never reaches game logic. - `CommandDefinition` (interface): interface CommandDefinition — ⚠ undocumented - `CommandRegistry` (interface): interface CommandRegistry — ⚠ undocumented - `CommandRejection` (interface): interface CommandRejection — ⚠ undocumented @@ -143,7 +145,8 @@ - `MultiplayerTopology` (type): type MultiplayerTopology = "shared" | "lobbies" | "private" — ⚠ undocumented - `ServersPoolConfig` (type): type ServersPoolConfig = { maxServers: number; slotsPerServer: number; minPlayersToStart?: number; adapter: MultiplayerAdapterConfig; } — ⚠ undocumented - `adapterOf` (function): function adapterOf(multiplayer: unknown): MultiplayerAdapterConfig | null — ⚠ undocumented -- `convex` (function): function convex(config?: { topology?: MultiplayerTopology; authority?: MultiplayerAuthority }): MultiplayerAdapterConfig — ⚠ undocumented +- `convex` (function): function convex(config?: { topology?: MultiplayerTopology; authority?: MultiplayerAuthority }): MultiplayerAdapterConfig — Convex transport. Omitting `authority` (or passing `"client"`) is **presence-only** — prefer `convexPresence()` to name that intent explicitly. Pass `{ authority: "server" }` for a shared, host-authoritative world — see `examples/HOSTED.md`. +- `convexPresence` (function): function convexPresence(config?: { topology?: MultiplayerTopology }): MultiplayerAdapterConfig — Presence-only Convex transport — each client runs its own `onTick`; only presence/feeds/chat sync. Sugar for `convex({ ...config, authority: "client" })`. - `fly` (function): function fly(config: { app: string; topology?: MultiplayerTopology; path?: string; authority?: MultiplayerAuthority }): MultiplayerAdapterConfig — ⚠ undocumented - `isOffline` (function): function isOffline(multiplayer: unknown): boolean — True for a single-player world — no adapter, or an explicit `offline()` one. Gates offline-only wiring like local whole-world save. - `isPresenceOnly` (function): function isPresenceOnly(multiplayer: unknown): boolean — True when multiplayer is on but the world sim is not host-authoritative — presence/feeds/chat only. Equivalent to `resolveAuthority(m) === "client"`. @@ -155,7 +158,8 @@ - `resolveAuthority` (function): function resolveAuthority(multiplayer: unknown): MultiplayerAuthority | null — Resolved authority for a multiplayer config. - `offline` / missing adapter → `null` (single-player; not multiplayer authority). - unset or `"client"` → `"client"` (presence-only; each client ticks). - `"server"` → host-authoritative shared sim. - `servers` (function): function servers(config: ServersPoolConfig): ServersPoolConfig — ⚠ undocumented - `socketIo` (function): function socketIo(config?: { topology?: MultiplayerTopology; url?: string; authority?: MultiplayerAuthority }): MultiplayerAdapterConfig — ⚠ undocumented -- `ws` (function): function ws(config?: { topology?: MultiplayerTopology; url?: string; authority?: MultiplayerAuthority }): MultiplayerAdapterConfig — ⚠ undocumented +- `ws` (function): function ws(config?: { topology?: MultiplayerTopology; url?: string; authority?: MultiplayerAuthority }): MultiplayerAdapterConfig — WebSocket transport. Omitting `authority` (or passing `"client"`) is **presence-only** — prefer `wsPresence()` to name that intent explicitly. Pass `{ authority: "server" }` for a shared, host-authoritative world — see `examples/HOSTED.md`. +- `wsPresence` (function): function wsPresence(config?: { topology?: MultiplayerTopology; url?: string }): MultiplayerAdapterConfig — Presence-only WebSocket transport — each client runs its own `onTick`; only presence/feeds/chat sync. Sugar for `ws({ ...config, authority: "client" })`. ## @jgengine/core/runtime/cameraDirector diff --git a/CLAUDE.md b/CLAUDE.md index 1b512b681..93d93089d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ Judge every engine change against three axes — extensibility, modularity, scal | Skill | Role | | --- | --- | | `jgengine` | Main skill: intake, foundation (shape, runtime, catalogs), selective domain routing | - | `jgengine-world` / `jgengine-procedural` | World runtime and generated environments | + | `jgengine-world` | World runtime and generated environments | | `jgengine-combat` / `jgengine-gameplay` | Combat and game systems | | `jgengine-multiplayer` | Networking, authority, persistence seams | | `jgengine-ui` / `jgengine-assets` | Interface and asset surfaces | diff --git a/packages/jgengine/src/skills.ts b/packages/jgengine/src/skills.ts index cdba2f9f4..35eacb396 100644 --- a/packages/jgengine/src/skills.ts +++ b/packages/jgengine/src/skills.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; import { cpSync, existsSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; @@ -9,12 +9,12 @@ export const SKILLS_SOURCE = "Noisemaker111/jgengine"; export const GAME_SKILLS = [ "jgengine", "jgengine-world", - "jgengine-procedural", "jgengine-combat", "jgengine-gameplay", "jgengine-multiplayer", "jgengine-ui", "jgengine-assets", + "jgengine-editor", "jgengine-verify", ] as const; diff --git a/scripts/api-doc-baseline.json b/scripts/api-doc-baseline.json index 106f0c3f9..2bf28503c 100644 --- a/scripts/api-doc-baseline.json +++ b/scripts/api-doc-baseline.json @@ -345,6 +345,7 @@ "@jgengine/core/combat/telegraph#HazardPhase", "@jgengine/core/combat/telegraph#TelegraphConfig", "@jgengine/core/combat/telegraph#TelegraphShape", + "@jgengine/core/commands/commandRegistry#CommandDecodeResult", "@jgengine/core/commands/commandRegistry#CommandDefinition", "@jgengine/core/commands/commandRegistry#CommandRegistry", "@jgengine/core/commands/commandRegistry#CommandRejection", @@ -539,7 +540,6 @@ "@jgengine/core/game/events#CombatTelegraphCancelledEvent", "@jgengine/core/game/events#CombatTelegraphEvent", "@jgengine/core/game/events#CosmeticsChangedEvent", - "@jgengine/core/game/events#DeathReason", "@jgengine/core/game/events#EmotePlayedEvent", "@jgengine/core/game/events#EntityDiedEvent", "@jgengine/core/game/events#EntityFloatTextEvent", @@ -1092,7 +1092,6 @@ "@jgengine/core/runtime/adapter#MultiplayerTopology", "@jgengine/core/runtime/adapter#ServersPoolConfig", "@jgengine/core/runtime/adapter#adapterOf", - "@jgengine/core/runtime/adapter#convex", "@jgengine/core/runtime/adapter#fly", "@jgengine/core/runtime/adapter#lan", "@jgengine/core/runtime/adapter#multiplayerAdapterKind", @@ -1100,7 +1099,6 @@ "@jgengine/core/runtime/adapter#p2p", "@jgengine/core/runtime/adapter#servers", "@jgengine/core/runtime/adapter#socketIo", - "@jgengine/core/runtime/adapter#ws", "@jgengine/core/runtime/cameraDirector#CameraDirector", "@jgengine/core/runtime/commandRunner#CommandDef", "@jgengine/core/runtime/commandRunner#CommandValidationError", @@ -1855,6 +1853,11 @@ "@jgengine/core/world/windZones#WindZoneState", "@jgengine/core/world/windZones#WindZonesConfig", "@jgengine/core/world/windZones#createWindZones", + "@jgengine/editor/chromeFields#NumberField", + "@jgengine/editor/chromeFields#SliderRow", + "@jgengine/editor/chromeStyles#BTN", + "@jgengine/editor/chromeStyles#INPUT", + "@jgengine/editor/chromeStyles#MICRO", "@jgengine/node#GameSocketIoServer", "@jgengine/node#GameSocketIoServerOptions", "@jgengine/node#GameWsServer", @@ -2141,6 +2144,7 @@ "@jgengine/react/voice#VoiceState", "@jgengine/shell/GameHost#GameHost", "@jgengine/shell/GameHost#GameHostProps", + "@jgengine/shell/GamePhaseStamp#GamePhaseStamp", "@jgengine/shell/GamePlayer#GamePlayer", "@jgengine/shell/GamePlayer#GamePlayerProps", "@jgengine/shell/GamePlayerShell#GamePlayerShell", @@ -2259,6 +2263,12 @@ "@jgengine/shell/cartridge/validate#validateCartridge", "@jgengine/shell/defineGame#GameConfig", "@jgengine/shell/defineGame#defineGame", + "@jgengine/shell/devtools/ColPanel#ColPanel", + "@jgengine/shell/devtools/KeysPanel#KeysPanel", + "@jgengine/shell/devtools/LogsPanel#LogsPanel", + "@jgengine/shell/devtools/NetPanel#NetPanel", + "@jgengine/shell/devtools/PerfPanel#PerfPanel", + "@jgengine/shell/devtools/TunePanel#TunePanel", "@jgengine/shell/devtools/collisionDebug#AimProbeConfig", "@jgengine/shell/devtools/collisionDebug#COLLISION_DEBUG_LAYERS", "@jgengine/shell/devtools/collisionDebug#CollisionDebugController", @@ -2280,6 +2290,18 @@ "@jgengine/shell/devtools/collisionDebugMath#DebugShapeEntry", "@jgengine/shell/devtools/collisionDebugMath#HITBOX_WIRE_COLOR", "@jgengine/shell/devtools/collisionDebugMath#PROJECTILE_PATH_COLOR", + "@jgengine/shell/devtools/devtoolsOverrides#readStoredOverrides", + "@jgengine/shell/devtools/panelAtoms#SectionLabel", + "@jgengine/shell/devtools/panelAtoms#StatRow", + "@jgengine/shell/devtools/panelAtoms#ms", + "@jgengine/shell/devtools/perfDiagnose#diagnose", + "@jgengine/shell/diagnostics/RuntimeDiagnostics#DiagnosticOverlay", + "@jgengine/shell/diagnostics/RuntimeDiagnostics#GameUiErrorBoundary", + "@jgengine/shell/diagnostics/RuntimeDiagnostics#RuntimeDiagnostic", + "@jgengine/shell/diagnostics/RuntimeDiagnostics#logRuntimeError", + "@jgengine/shell/drivers/FrameDriver#FrameDriver", + "@jgengine/shell/drivers/FrameDriver#POSTER_SETTLE_SECONDS", + "@jgengine/shell/drivers/HudOnlyDriver#HudOnlyDriver", "@jgengine/shell/environment#DaylightCycleConfig", "@jgengine/shell/environment#DaylightProps", "@jgengine/shell/environment#DaylightState", @@ -2337,6 +2359,11 @@ "@jgengine/shell/registry#RenderEntity", "@jgengine/shell/registry#RenderObject", "@jgengine/shell/registry#resolveGameLoader", + "@jgengine/shell/render/SceneLighting#BackdropFog", + "@jgengine/shell/render/SceneLighting#ConfiguredLighting", + "@jgengine/shell/render/SceneModels#EntityModel", + "@jgengine/shell/render/SceneModels#EntitySprite", + "@jgengine/shell/render/SceneModels#IsolatedEntityModel", "@jgengine/shell/render/modelRender#MaterialCache", "@jgengine/shell/render/modelRender#PAINT_TEXTURE_SIZE", "@jgengine/shell/render/modelRender#PaintCanvas", @@ -2528,6 +2555,8 @@ "@jgengine/shell/world/WorldHud#WorldEntityBars", "@jgengine/shell/world/WorldHud#WorldFloatText", "@jgengine/shell/world/WorldHud#WorldTelegraphs", + "@jgengine/shell/world/WorldScene#RemotePlayers", + "@jgengine/shell/world/WorldScene#WorldView", "@jgengine/shell/world/entityPose#PoseSource", "@jgengine/shell/world/entityPose#PoseWritable", "@jgengine/shell/world/floatTextStyle#FloatTextInfo", diff --git a/scripts/api-orphan-baseline.json b/scripts/api-orphan-baseline.json index bfb8b796e..603579691 100644 --- a/scripts/api-orphan-baseline.json +++ b/scripts/api-orphan-baseline.json @@ -161,6 +161,8 @@ "@jgengine/core/random/nameGen#fillTemplate", "@jgengine/core/random/nameGen#pickFrom", "@jgengine/core/random/rng#hashString", + "@jgengine/core/random/rng#randomSeedFrom", + "@jgengine/core/random/rng#stepRandomSeed", "@jgengine/core/scene/assetPreload#createSceneAssetPreloader", "@jgengine/core/scene/autoTarget#createAutoTargeter", "@jgengine/core/scene/behaviors#promptable", @@ -228,12 +230,8 @@ "@jgengine/core/world/water#synthesizeWaves", "@jgengine/core/world/windZones#createWindZones", "@jgengine/editor#AssetBrowser", - "@jgengine/editor#EditorCameraDriver", "@jgengine/editor#EditorLayerOverlays", "@jgengine/editor#PathDraftPreview", - "@jgengine/editor#PerfProbe", - "@jgengine/editor#SelectionGizmo", - "@jgengine/editor#ViewportSelect", "@jgengine/editor#assetsFromCatalog", "@jgengine/editor#createEditorHost", "@jgengine/editor#createEditorUiStore", @@ -244,12 +242,13 @@ "@jgengine/editor/AssetBrowser#assetsFromCatalog", "@jgengine/editor/DebugDraw#EditorLayerOverlays", "@jgengine/editor/DebugDraw#PathDraftPreview", - "@jgengine/editor/EditorCameraDriver#EditorCameraDriver", - "@jgengine/editor/PerfProbe#PerfProbe", - "@jgengine/editor/SelectionGizmo#SelectionGizmo", - "@jgengine/editor/SelectionGizmo#ViewportSelect", + "@jgengine/editor/TerrainPanel#TerrainPanel", + "@jgengine/editor/chromeFields#NumberField", + "@jgengine/editor/chromeFields#SliderRow", "@jgengine/editor/mcp/bridgeServer.node#startEditorBridgeServerNode", + "@jgengine/editor/mcp/loadGameLayers#decodeGameLayers", "@jgengine/editor/mcp/loadGameLayers#loadGameLayers", + "@jgengine/editor/mcp/rpcRequest#decodeEditorBridgeRequest", "@jgengine/editor/mcp/stdioServer#runEditorMcpStdio", "@jgengine/editor/session#createEditorHost", "@jgengine/editor/session#installEditorHost", @@ -257,13 +256,12 @@ "@jgengine/editor/uiStore#newPlacementId", "@jgengine/editor/useF2Chord#useF2Chord", "@jgengine/node#clearFilePersistence", - "@jgengine/node#createWorldGameServer", "@jgengine/node#toWebRequest", "@jgengine/node/persistence#clearFilePersistence", "@jgengine/node/testFixtures#createChunkTestRuntime", "@jgengine/node/testFixtures#createTestRuntime", "@jgengine/node/webHandler#toWebRequest", - "@jgengine/node/worldServer#createWorldGameServer", + "@jgengine/shell/GamePhaseStamp#GamePhaseStamp", "@jgengine/shell/audio/AudioComponents#AudioListener", "@jgengine/shell/audio/AudioComponents#EntityAudioEmitters", "@jgengine/shell/audio/AudioComponents#ObjectAudioEmitters", @@ -297,7 +295,23 @@ "@jgengine/shell/camera/cameraRigs#ShoulderRig", "@jgengine/shell/camera/cameraRigs#SideScrollRig", "@jgengine/shell/camera/cameraRigs#TopDownRig", + "@jgengine/shell/devtools/ColPanel#ColPanel", "@jgengine/shell/devtools/CollisionDebugWorld#CollisionDebugWorld", + "@jgengine/shell/devtools/KeysPanel#KeysPanel", + "@jgengine/shell/devtools/LogsPanel#LogsPanel", + "@jgengine/shell/devtools/NetPanel#NetPanel", + "@jgengine/shell/devtools/PerfPanel#PerfPanel", + "@jgengine/shell/devtools/TunePanel#TunePanel", + "@jgengine/shell/devtools/devtoolsOverrides#readStoredOverrides", + "@jgengine/shell/devtools/panelAtoms#SectionLabel", + "@jgengine/shell/devtools/panelAtoms#StatRow", + "@jgengine/shell/devtools/panelAtoms#ms", + "@jgengine/shell/devtools/perfDiagnose#diagnose", + "@jgengine/shell/diagnostics/RuntimeDiagnostics#DiagnosticOverlay", + "@jgengine/shell/diagnostics/RuntimeDiagnostics#GameUiErrorBoundary", + "@jgengine/shell/diagnostics/RuntimeDiagnostics#logRuntimeError", + "@jgengine/shell/drivers/FrameDriver#FrameDriver", + "@jgengine/shell/drivers/HudOnlyDriver#HudOnlyDriver", "@jgengine/shell/environment/GroundPad#GroundPad", "@jgengine/shell/environment/RoadRibbons#RoadRibbons", "@jgengine/shell/input/mouseLook#createMouseLookTracker", @@ -308,6 +322,11 @@ "@jgengine/shell/postfx/PostProcessing#PostProcessing", "@jgengine/shell/postfx/gradeShader#createGradePass", "@jgengine/shell/registry#resolveGameLoader", + "@jgengine/shell/render/SceneLighting#BackdropFog", + "@jgengine/shell/render/SceneLighting#ConfiguredLighting", + "@jgengine/shell/render/SceneModels#EntityModel", + "@jgengine/shell/render/SceneModels#EntitySprite", + "@jgengine/shell/render/SceneModels#IsolatedEntityModel", "@jgengine/shell/settings/QuickControls#QuickControls", "@jgengine/shell/settings/SettingsChrome#SettingsChrome", "@jgengine/shell/settings/SettingsMenu#SettingsMenu", @@ -326,6 +345,7 @@ "@jgengine/shell/touch/TouchControlsOverlay#TouchControlsDock", "@jgengine/shell/touch/TouchControlsOverlay#primaryButtonOffsets", "@jgengine/shell/touch/TouchControlsOverlay#touchDockClearance", + "@jgengine/shell/useShellMultiplayerSync#useShellMultiplayerSync", "@jgengine/shell/visibility/CullingProvider#CullingProvider", "@jgengine/shell/visibility/CullingProvider#useRenderVisibility", "@jgengine/shell/vision/FrustumSensorHud#frustumSampleDisplayEqual", @@ -355,6 +375,8 @@ "@jgengine/shell/world/WorldHud#WorldFloatText", "@jgengine/shell/world/WorldHud#WorldTelegraphs", "@jgengine/shell/world/WorldItems#WorldItems", + "@jgengine/shell/world/WorldScene#RemotePlayers", + "@jgengine/shell/world/WorldScene#WorldView", "@jgengine/ws#computeVoiceGain", "@jgengine/ws/voiceChannel#computeVoiceGain" ] diff --git a/scripts/check-skill-sync.ts b/scripts/check-skill-sync.ts index 7a77d3aa0..2003a9d79 100644 --- a/scripts/check-skill-sync.ts +++ b/scripts/check-skill-sync.ts @@ -1,6 +1,8 @@ -import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; +import { SKILL_DIRS } from "./skillRouting"; + const root = process.cwd(); if (existsSync(join(root, "skills"))) { @@ -12,17 +14,7 @@ if (existsSync(join(root, "skills"))) { } const skillsRoot = join(root, ".claude", "skills"); -const requiredSkills = [ - "jgengine", - "jgengine-world", - "jgengine-procedural", - "jgengine-combat", - "jgengine-gameplay", - "jgengine-multiplayer", - "jgengine-ui", - "jgengine-assets", - "jgengine-verify", -]; +const requiredSkills = [...SKILL_DIRS, "jgengine-verify"]; for (const required of requiredSkills) { if (!existsSync(join(skillsRoot, required, "SKILL.md"))) { @@ -43,7 +35,15 @@ for (const required of requiredSkills) { for (const name of readdirSync(skillsRoot)) { const skillPath = join(skillsRoot, name, "SKILL.md"); if (!existsSync(skillPath)) continue; - const frontmatter = readFileSync(skillPath, "utf8").match(/^---\n([\s\S]*?)\n---/)?.[1] ?? ""; + const raw = readFileSync(skillPath, "utf8"); + if (raw.charCodeAt(0) === 0xfeff) { + console.error( + `check-skill-sync: .claude/skills/${name}/SKILL.md starts with a UTF-8 BOM — ` + + "the frontmatter parser anchors on byte 0, so the description turns invisible. Strip the BOM.", + ); + process.exit(1); + } + const frontmatter = raw.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? ""; if (/disable-model-invocation:\s*true/.test(frontmatter)) continue; const description = (frontmatter.match(/^description:\s*>?-?\s*([\s\S]*?)(?=\n\S|$)/m)?.[1] ?? "") .replace(/\n\s+/g, " ") diff --git a/scripts/gen-skill-api-safe.ts b/scripts/gen-skill-api-safe.ts index d175c98da..14e8de911 100644 --- a/scripts/gen-skill-api-safe.ts +++ b/scripts/gen-skill-api-safe.ts @@ -3,20 +3,12 @@ import { join } from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { SKILL_DIRS } from "./skillRouting"; + const root = fileURLToPath(new URL("..", import.meta.url)); const baselinePath = join(root, "scripts/api-doc-baseline.json"); const orphanBaselinePath = join(root, "scripts/api-orphan-baseline.json"); -const skills = [ - "jgengine", - "jgengine-world", - "jgengine-procedural", - "jgengine-combat", - "jgengine-gameplay", - "jgengine-multiplayer", - "jgengine-ui", - "jgengine-assets", - "jgengine-editor", -]; +const skills = SKILL_DIRS; const trackedPaths = [ baselinePath, orphanBaselinePath, diff --git a/scripts/skillRouting.ts b/scripts/skillRouting.ts index 8515c630f..2c9372b67 100644 --- a/scripts/skillRouting.ts +++ b/scripts/skillRouting.ts @@ -37,7 +37,7 @@ export const CORE_DOMAIN_SKILLS: Record = { session: "jgengine-gameplay", settings: "jgengine-ui", stats: "jgengine-combat", - survival: "jgengine-procedural", + survival: "jgengine-gameplay", tactics: "jgengine-combat", time: "jgengine-world", turn: "jgengine-gameplay", @@ -68,7 +68,6 @@ export const PACKAGE_DOMAIN_OVERRIDES: Record> = export const SKILL_DIRS = [ MAIN, "jgengine-world", - "jgengine-procedural", "jgengine-combat", "jgengine-gameplay", "jgengine-multiplayer", diff --git a/scripts/stage-skills.ts b/scripts/stage-skills.ts index e6863f699..ad7a3c377 100644 --- a/scripts/stage-skills.ts +++ b/scripts/stage-skills.ts @@ -3,20 +3,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -const ALL_GAME_SKILLS = [ - "jgengine", - "jgengine-world", - "jgengine-procedural", - "jgengine-combat", - "jgengine-gameplay", - "jgengine-multiplayer", - "jgengine-ui", - "jgengine-assets", - "jgengine-verify", -] as const; +import { SKILL_DIRS } from "./skillRouting"; + +const ALL_GAME_SKILLS = [...SKILL_DIRS, "jgengine-verify"] as const; const PACKAGE_SKILLS: Record = { - core: ["jgengine", "jgengine-gameplay", "jgengine-combat", "jgengine-world", "jgengine-procedural"], + core: ["jgengine", "jgengine-gameplay", "jgengine-combat", "jgengine-world"], ws: ["jgengine", "jgengine-multiplayer"], sql: ["jgengine", "jgengine-multiplayer"], convex: ["jgengine", "jgengine-multiplayer"], From 337584f807312e6dd95b1c5088917106e75977b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 16:52:18 +0000 Subject: [PATCH 2/2] Regenerate skill api.md with warm install Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JuehpjvwcXpPYqy9yM9Hza --- .claude/skills/jgengine-assets/api.md | 7 +++++-- .claude/skills/jgengine-editor/api.md | 4 ++++ .claude/skills/jgengine-multiplayer/api.md | 17 +++++++++++++++++ .claude/skills/jgengine-ui/api.md | 20 ++++++++++++++++++-- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/.claude/skills/jgengine-assets/api.md b/.claude/skills/jgengine-assets/api.md index fae992b11..6c194d8f3 100644 --- a/.claude/skills/jgengine-assets/api.md +++ b/.claude/skills/jgengine-assets/api.md @@ -23,6 +23,7 @@ - `MaterialMapRole` (type): type MaterialMapRole = keyof typeof MATERIAL_MAP_FILES — One PBR map's role within a material: color, normal, roughness, ao, or displacement. - `MaterialMaps` (interface): interface MaterialMaps — URLs of one material's PBR maps; `ao`/`displacement` files may be absent from a rare pack. - `MaterialRef` (interface): interface MaterialRef — A resolved material: identity, attribution, and its normalized map URLs. +- `ModelDims` (interface): interface ModelDims — Measured horizontal footprint, footprint center, and lowest Y of a model in model space. - `ModelSnippetOptions` (interface): interface ModelSnippetOptions — ⚠ undocumented - `PinnedDownload` (interface): interface PinnedDownload — ⚠ undocumented - `RankedMatch` (interface): interface RankedMatch — ⚠ undocumented @@ -39,7 +40,7 @@ - `buildSpriteCatalog` (function): function buildSpriteCatalog(options: BuildSpriteCatalogOptions = {}): AssetCatalog — Resolves individual pulled sprite/icon ids (e.g. `gameicons-icons/sword`) to `{ url }`. - `componentInstallUrl` (function): function componentInstallUrl(name: string): string — The `shadcn add` URL for a HUD component, e.g. `https://jgengine.com/r/vital-bar.json`. - `componentWiringSnippet` (function): function componentWiringSnippet(component: RegistryComponent): string — Copy-paste wiring for a HUD component: the `shadcn add` command plus import + usage. -- `createStarterCatalog` (function): function createStarterCatalog(options: BuildCatalogOptions = {}): AssetCatalog — ⚠ undocumented +- `createStarterCatalog` (function): function createStarterCatalog(options: BuildCatalogOptions = {}): AssetCatalog — ⚠ undocumented - `entryForSpriteFile` (function): function entryForSpriteFile(source: AssetSource, file: string): IndexEntry — Builds one sprite/icon `IndexEntry` — same shape as a model entry, minus `dims`. - `entryUrl` (function): function entryUrl(basePath: string, entry: IndexEntry): string — ⚠ undocumented - `extractMaterialMaps` (function): function extractMaterialMaps(archive: Uint8Array): ExtractedMaterialMap[] — Pulls the recognized PBR maps out of a material archive (ambientCG's flat `__.jpg` layout) and normalizes their names so resolved URLs never depend on the provider's naming or the pulled resolution. @@ -88,7 +89,7 @@ ## @jgengine/assets/catalogs/starter -- `createStarterCatalog` (function): function createStarterCatalog(options: BuildCatalogOptions = {}): AssetCatalog — ⚠ undocumented +- `createStarterCatalog` (function): function createStarterCatalog(options: BuildCatalogOptions = {}): AssetCatalog — ⚠ undocumented ## @jgengine/assets/cli/pull @@ -98,6 +99,7 @@ ## @jgengine/assets/dims +- `ModelDims` (interface): interface ModelDims — Measured horizontal footprint, footprint center, and lowest Y of a model in model space. - `readGlbDims` (function): function readGlbDims(bytes: Uint8Array): ModelDims | null — ⚠ undocumented ## @jgengine/assets/download @@ -149,6 +151,7 @@ - `AssetSource` (interface): interface AssetSource — ⚠ undocumented - `AssetSourceKind` (type): type AssetSourceKind = "model" | "material" | "sprite" — What a source's archive contains: GLB models (default), one PBR material's texture maps, or a pack of individual 2D sprite/icon files (SVG/PNG). - `IndexEntry` (interface): interface IndexEntry — ⚠ undocumented +- `ModelDims` (interface): interface ModelDims — Measured horizontal footprint, footprint center, and lowest Y of a model in model space. - `PinnedDownload` (interface): interface PinnedDownload — ⚠ undocumented - `ScrapeDownload` (interface): interface ScrapeDownload — ⚠ undocumented - `SingleAsset` (interface): interface SingleAsset — ⚠ undocumented diff --git a/.claude/skills/jgengine-editor/api.md b/.claude/skills/jgengine-editor/api.md index c33d8da31..0d7d41bd6 100644 --- a/.claude/skills/jgengine-editor/api.md +++ b/.claude/skills/jgengine-editor/api.md @@ -224,9 +224,13 @@ - `EditorAssetInfo` (interface): interface EditorAssetInfo — A placeable asset entry offered in the editor's asset browser. - `EditorBridgeRequest` (type): type EditorBridgeRequest = | { method: "editor_status" } | { method: "set_mode"; mode: EditorRunMode } | { method: "perf_report" } | { method: "list_layers" } | { method: "list_selection" } | { method: "get_marker"; id: string } | { method: "get_volume"; id: string } | { method: "set_transform"; id:… — RPC request shapes the editor host understands, used by the MCP bridge and UI. - `EditorBridgeResponse` (type): type EditorBridgeResponse = { ok: boolean; result?: unknown; error?: string; } — Result envelope returned by every editor host RPC call. +- `EditorDocument` (interface): interface EditorDocument — The full authored scene: every marker, volume, path, note, and sculpted terrain for a game. - `EditorHostApi` (interface): interface EditorHostApi — The live editor's global control surface — session, visibility, camera focus, assets, mode, RPC. +- `EditorKindVisibility` (interface): interface EditorKindVisibility — Per-kind show/hide flags for the editor's layer panel. - `EditorPerfSample` (interface): interface EditorPerfSample — Rolling frame-rate sample published by the in-canvas PerfProbe. - `EditorRunMode` (type): type EditorRunMode = "edit" | "walk" | "play" — How the editor hosts the game: frozen placement view, roamable world, or the real game. +- `EditorSession` (interface): interface EditorSession — Stateful, undoable handle for driving scene edits from UI or an MCP agent. +- `EditorSessionState` (interface): interface EditorSessionState — The document plus current selection at a point in editor history. - `createEditorHost` (function): function createEditorHost(options: { gameId: string; layers: EditorLayersInput | undefined; assets?: readonly EditorAssetInfo[]; onFocus?: (target: { x: number; y: number; z: number } | null) => void; }): { session: EditorSession; api: EditorHostApi; dispose: () => void; } — Builds and installs an editor host for a game: session, visibility, assets, and RPC handling. - `getEditorHost` (function): function getEditorHost(): EditorHostApi | null — Retrieves the globally installed editor host, or null if none is mounted. - `installEditorHost` (function): function installEditorHost(api: EditorHostApi): () => void — Publishes an editor host globally so devtools and MCP agents can reach it; returns a cleanup fn. diff --git a/.claude/skills/jgengine-multiplayer/api.md b/.claude/skills/jgengine-multiplayer/api.md index 2b9f0437d..6dcd3b592 100644 --- a/.claude/skills/jgengine-multiplayer/api.md +++ b/.claude/skills/jgengine-multiplayer/api.md @@ -206,13 +206,17 @@ - `DEFAULT_HEARTBEAT_INTERVAL_MS` (const): const DEFAULT_HEARTBEAT_INTERVAL_MS: 30000 — Default ping/pong interval; a socket that misses one round-trip is terminated. - `DEFAULT_MAX_CONNECTIONS` (const): const DEFAULT_MAX_CONNECTIONS: 10000 — Default max concurrent sockets this server accepts before rejecting new ones. - `DEFAULT_MAX_PAYLOAD_BYTES` (const): const DEFAULT_MAX_PAYLOAD_BYTES: 1048576 — Default per-message payload cap (bytes) — `ws` closes the socket with 1009 past this. +- `GameHost` (type): type GameHost = { joinServer: (args: { userId: string; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; }) => Promise; browseServers: (args: { gameId: string; filter?: MatchFilter; limit?: number; }) => Promise; joinByCode: (args: … — A transport-agnostic authoritative game server host that manages sessions, ticking, and persistence. +- `GameHostOptions` (type): type GameHostOptions = { runtimes?: GameRuntime[]; persistence: HostPersistence; tickMs?: number; slotsPerServer?: number; now?: () => number; createServerId?: () => string; allowedFeedActions?: readonly string[]; } — Configuration for {@link createGameHost}, including persistence, tick rate, and game runtimes. - `GameSocketIoServer` (type): type GameSocketIoServer = { rewind: (args: { serverId: string; atMs: number }) => RewoundPosition[]; close: () => void; } — ⚠ undocumented - `GameSocketIoServerOptions` (type): type GameSocketIoServerOptions = HostRouterOptions & { io: SocketIoLikeServer } — ⚠ undocumented - `GameWsServer` (type): type GameWsServer = { wss: WebSocketServer; port: () => number; rewind: (args: { serverId: string; atMs: number }) => RewoundPosition[]; close: () => Promise; } — ⚠ undocumented - `GameWsServerOptions` (type): type GameWsServerOptions = HostRouterOptions & { server?: HttpServer; port?: number; path?: string; /** Per-message payload cap in bytes. Defaults to {@link DEFAULT_MAX_PAYLOAD_BYTES}. */ maxPayloadBytes?: number; /** Max concurrent sockets accepted; connections beyond this are closed immediately. D… — ⚠ undocumented +- `HostChangeEvent` (type): type HostChangeEvent = { type: "server"; serverId: string; } | { type: "player"; serverId: string; userId: string; } | { type: "feed"; serverId: string; action: string; } — A change notification emitted by a `GameHost` for a server, player, or feed mutation. - `HostedGameDefinition` (interface): interface HostedGameDefinition — A game the world server can host — its authoritative {@link GameDefinition} and the content lookup a `GameContext` reads. - `InstallShutdownHookOptions` (interface): interface InstallShutdownHookOptions — Config for {@link installShutdownHook}. - `NodeHandler` (type): type NodeHandler = (req: IncomingMessage, res: ServerResponse) => void — ⚠ undocumented +- `RewoundPosition` (type): type RewoundPosition = { userId: string; x: number; y: number; z: number; } — A player's interpolated position sampled from history at a past timestamp. - `ShutdownHook` (interface): interface ShutdownHook — A live signal listener installed by {@link installShutdownHook}; call `remove()` to uninstall it (tests, embedders opting out). - `SocketIoLikeServer` (type): type SocketIoLikeServer = { on: (event: "connection", listener: (socket: SocketIoLikeServerSocket) => void) => unknown; } — ⚠ undocumented - `SocketIoLikeServerSocket` (type): type SocketIoLikeServerSocket = { on: (event: string, listener: (payload: string) => void) => unknown; send: (data: string) => unknown; disconnect: (close?: boolean) => unknown; } — ⚠ undocumented @@ -223,10 +227,12 @@ - `WorldPersistenceKey` (interface): interface WorldPersistenceKey — Per-world key a {@link WorldPersistence} resolves a {@link HostedWorldStore} for. - `attachGameSocketIoServer` (function): function attachGameSocketIoServer(options: GameSocketIoServerOptions): GameSocketIoServer — ⚠ undocumented - `clearFilePersistence` (function): function clearFilePersistence(dir: string): Promise — ⚠ undocumented +- `createGameHost` (function): function createGameHost(options: GameHostOptions): GameHost — Creates a `GameHost` that runs game servers over the given persistence and runtimes. - `createGameWsServer` (function): function createGameWsServer(options: GameWsServerOptions): GameWsServer — ⚠ undocumented - `createWorldGameServer` (function): function createWorldGameServer(options: WorldGameServerOptions): WorldGameServer — Build a {@link WorldGameServer} — one process hosting authoritative GameContext worlds over ws, ready for two-client play once {@link WorldGameServer.start} runs. - `filePersistence` (function): function filePersistence(dir: string, now: () => number = Date.now): HostPersistence — ⚠ undocumented - `installShutdownHook` (function): function installShutdownHook(shutdown: () => Promise | void, options: InstallShutdownHookOptions = {}): ShutdownHook — Wires `SIGINT`/`SIGTERM` (or a custom signal list) to a clean-shutdown callback — e.g. `() => worldServer.close()` or `() => Promise.all([wsServer.close(), host.stop()])`. Bounded by `timeoutMs` so a stuck flush can't hang the process forever; idempotent — a second signal delivered mid-shutdown reuses the same in-flight run instead of flushing twice. Returns a {@link ShutdownHook} whose `remove()` uninstalls the listeners, for tests and embedders that want their own handling. +- `memoryPersistence` (function): function memoryPersistence(now?: () => number): HostPersistence — Creates an in-memory `HostPersistence` implementation, useful for tests and ephemeral hosts. - `memoryWorldPersistence` (function): function memoryWorldPersistence(): WorldPersistence — Default {@link WorldPersistence}: an isolated in-memory {@link HostedWorldStore} per `gameId`/`serverId`, lost on process exit. - `toNodeHandler` (function): function toNodeHandler(handler: WebHandler): NodeHandler — ⚠ undocumented - `toWebRequest` (function): function toWebRequest(req: IncomingMessage): Promise — ⚠ undocumented @@ -245,12 +251,21 @@ - `EditorManifest` (interface): interface EditorManifest — What the standalone editor loads on boot: the on-disk scene document (if any) and every model in the asset folder. - `EditorManifestAsset` (interface): interface EditorManifestAsset — One placeable model the standalone editor lists — a stable id and a URL the dev server serves it from. +## @jgengine/node/host + +- `GameHost` (type): type GameHost = { joinServer: (args: { userId: string; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; }) => Promise; browseServers: (args: { gameId: string; filter?: MatchFilter; limit?: number; }) => Promise; joinByCode: (args: … — A transport-agnostic authoritative game server host that manages sessions, ticking, and persistence. +- `GameHostOptions` (type): type GameHostOptions = { runtimes?: GameRuntime[]; persistence: HostPersistence; tickMs?: number; slotsPerServer?: number; now?: () => number; createServerId?: () => string; allowedFeedActions?: readonly string[]; } — Configuration for {@link createGameHost}, including persistence, tick rate, and game runtimes. +- `HostChangeEvent` (type): type HostChangeEvent = { type: "server"; serverId: string; } | { type: "player"; serverId: string; userId: string; } | { type: "feed"; serverId: string; action: string; } — A change notification emitted by a `GameHost` for a server, player, or feed mutation. +- `createGameHost` (function): function createGameHost(options: GameHostOptions): GameHost — Creates a `GameHost` that runs game servers over the given persistence and runtimes. +- `memoryPersistence` (function): function memoryPersistence(now?: () => number): HostPersistence — Creates an in-memory `HostPersistence` implementation, useful for tests and ephemeral hosts. + ## @jgengine/node/persistence - `WorldPersistence` (interface): interface WorldPersistence — The persistence plug-point for {@link createWorldGameServer}: resolves one {@link HostedWorldStore} per hosted world, called once when the world host session is created. Structural — a SQL, file, or Convex-backed store all conform without `node` importing a concrete driver; only a `store()` factory is required. Mirrors `HostPersistence` (the reducer host's persistence seam). - `WorldPersistenceKey` (interface): interface WorldPersistenceKey — Per-world key a {@link WorldPersistence} resolves a {@link HostedWorldStore} for. - `clearFilePersistence` (function): function clearFilePersistence(dir: string): Promise — ⚠ undocumented - `filePersistence` (function): function filePersistence(dir: string, now: () => number = Date.now): HostPersistence — ⚠ undocumented +- `memoryPersistence` (function): function memoryPersistence(now?: () => number): HostPersistence — Creates an in-memory `HostPersistence` implementation, useful for tests and ephemeral hosts. - `memoryWorldPersistence` (function): function memoryWorldPersistence(): WorldPersistence — Default {@link WorldPersistence}: an isolated in-memory {@link HostedWorldStore} per `gameId`/`serverId`, lost on process exit. ## @jgengine/node/shutdown @@ -263,6 +278,7 @@ - `GameSocketIoServer` (type): type GameSocketIoServer = { rewind: (args: { serverId: string; atMs: number }) => RewoundPosition[]; close: () => void; } — ⚠ undocumented - `GameSocketIoServerOptions` (type): type GameSocketIoServerOptions = HostRouterOptions & { io: SocketIoLikeServer } — ⚠ undocumented +- `RewoundPosition` (type): type RewoundPosition = { userId: string; x: number; y: number; z: number; } — A player's interpolated position sampled from history at a past timestamp. - `SocketIoLikeServer` (type): type SocketIoLikeServer = { on: (event: "connection", listener: (socket: SocketIoLikeServerSocket) => void) => unknown; } — ⚠ undocumented - `SocketIoLikeServerSocket` (type): type SocketIoLikeServerSocket = { on: (event: string, listener: (payload: string) => void) => unknown; send: (data: string) => unknown; disconnect: (close?: boolean) => unknown; } — ⚠ undocumented - `attachGameSocketIoServer` (function): function attachGameSocketIoServer(options: GameSocketIoServerOptions): GameSocketIoServer — ⚠ undocumented @@ -293,6 +309,7 @@ - `DEFAULT_MAX_PAYLOAD_BYTES` (const): const DEFAULT_MAX_PAYLOAD_BYTES: 1048576 — Default per-message payload cap (bytes) — `ws` closes the socket with 1009 past this. - `GameWsServer` (type): type GameWsServer = { wss: WebSocketServer; port: () => number; rewind: (args: { serverId: string; atMs: number }) => RewoundPosition[]; close: () => Promise; } — ⚠ undocumented - `GameWsServerOptions` (type): type GameWsServerOptions = HostRouterOptions & { server?: HttpServer; port?: number; path?: string; /** Per-message payload cap in bytes. Defaults to {@link DEFAULT_MAX_PAYLOAD_BYTES}. */ maxPayloadBytes?: number; /** Max concurrent sockets accepted; connections beyond this are closed immediately. D… — ⚠ undocumented +- `RewoundPosition` (type): type RewoundPosition = { userId: string; x: number; y: number; z: number; } — A player's interpolated position sampled from history at a past timestamp. - `createGameWsServer` (function): function createGameWsServer(options: GameWsServerOptions): GameWsServer — ⚠ undocumented ## @jgengine/sql diff --git a/.claude/skills/jgengine-ui/api.md b/.claude/skills/jgengine-ui/api.md index c1f4b2bec..72c8e390b 100644 --- a/.claude/skills/jgengine-ui/api.md +++ b/.claude/skills/jgengine-ui/api.md @@ -655,6 +655,7 @@ ## @jgengine/shell/GamePlayerShell - `GamePlayerShell` (function): function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null, poster = false, onContextReady, }: { playable: PlayableGame; multiplayer?: ShellMultiplayer | null; poster?: boolean; /** Called once per boot after onInit/onNewPlayer with the live GameContext — a staging seam for screenshots,… — ⚠ undocumented +- `resolvePhysicsTuning` (function): function resolvePhysicsTuning(physics: PhysicsConfig | undefined): MovementTuningOverrides | undefined — Maps a game's declared `physics` onto the movement controllers' tuning. `PhysicsConfig.gravity` is a signed world acceleration (negative points down), but the controllers integrate `velocityY -= gravityAcceleration * dt` and expect a positive downward magnitude — so gravity is negated here to keep down-pointing gravity pulling down. ## @jgengine/shell/GameUiPreview @@ -772,7 +773,7 @@ ## @jgengine/shell/camera/fovPreference -- `PLAYER_FOV_DEFAULT` (const): const PLAYER_FOV_DEFAULT: any — ⚠ undocumented +- `PLAYER_FOV_DEFAULT` (const): const PLAYER_FOV_DEFAULT: 55 — ⚠ undocumented - `PLAYER_FOV_MAX` (const): const PLAYER_FOV_MAX: 120 — ⚠ undocumented - `PLAYER_FOV_MIN` (const): const PLAYER_FOV_MIN: 40 — ⚠ undocumented - `PLAYER_FOV_STORAGE_KEY` (const): const PLAYER_FOV_STORAGE_KEY: "jgengine:player-fov" — ⚠ undocumented @@ -780,6 +781,8 @@ ## @jgengine/shell/camera/inspectionCameraMath +- `InspectionCameraConfig` (interface): interface InspectionCameraConfig — Model-viewer / inspection rig (#207.7) — orbit + pan + anchored zoom around a fixed point, never reads player input. +- `InspectionZoomAnchor` (type): type InspectionZoomAnchor = "target" | "cursor" | "center" — How scroll-zoom re-anchors the view for the inspection rig (#207.7): - `target` — dolly toward the orbit target (classic OrbitControls behavior). - `cursor` — dolly toward the point under the pointer. - `center` — dolly toward the viewport center; equivalent to `target` for an OrbitControls-driven rig, since the camera always faces `target` and that point already projects to the exact center of the viewport. - `ResolvedInspectionCameraConfig` (interface): interface ResolvedInspectionCameraConfig — ⚠ undocumented ## @jgengine/shell/camera/orbitCameraMath @@ -1175,7 +1178,12 @@ ## @jgengine/shell/settings/settingsController +- `SettingsActionView` (interface): interface SettingsActionView — A resolved game-state action — `run` is already bound to the game context and closes the menu. +- `SettingsCategoryView` (interface): interface SettingsCategoryView — A settings menu category with its rows and keybinds, ready to render. +- `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. - `SettingsControllerInput` (interface): interface SettingsControllerInput — ⚠ undocumented +- `SettingsKeybindRow` (interface): interface SettingsKeybindRow — One rebindable action row rendered in the controls settings category. +- `SettingsRow` (interface): interface SettingsRow — One editable setting rendered in a settings menu category. - `useSettingsCategories` (function): function useSettingsCategories(config: SettingsControllerInput): SettingsCategoryView[] — ⚠ undocumented ## @jgengine/shell/shellConstants @@ -1236,6 +1244,7 @@ - `EditableGround` (function): function EditableGround({ terrain, bounds, segments = 96, version = 0, baseColor = "#3f6b3a", surfaceColors = DEFAULT_SURFACE_COLORS, }: EditableGroundProps): React.JSX.Element — ⚠ undocumented - `EditableGroundProps` (interface): interface EditableGroundProps — ⚠ undocumented - `FieldGroundOptions` (interface): interface FieldGroundOptions — ⚠ undocumented +- `FractalNoiseConfig` (interface): interface FractalNoiseConfig — Octave settings for {@link fractalNoise}: frequency, layering, and optional ridged shaping. - `GrassBladeGeometryOptions` (interface): interface GrassBladeGeometryOptions — ⚠ undocumented - `GrassField` (function): function GrassField({ count = DEFAULT_GRASS_COUNT, density = DEFAULT_GRASS_DENSITY, budget, area = 40, seed = 1, segments = 4, bladeHeight, bladeWidth, bladeBend, heightAt, colorBase, colorTip, colorVariation, wind, roughness, castShadow = false, receiveShadow = true, frustumCulled = true, ...meshPr… — ⚠ undocumented - `GrassFieldProps` (interface): interface GrassFieldProps extends Omit — ⚠ undocumented @@ -1244,6 +1253,7 @@ - `GrassRange` (type): type GrassRange = number | readonly [min: number, max: number] — ⚠ undocumented - `GrassShaderUniforms` (interface): interface GrassShaderUniforms — ⚠ undocumented - `GrassWindOptions` (interface): interface GrassWindOptions — ⚠ undocumented +- `NoiseFieldConfig` (interface): interface NoiseFieldConfig — Configuration for {@link noiseField}: seed, amplitude, and fractal noise shaping. - `ProceduralGround` (function): function ProceduralGround({ terrain, colors, roughness = 0.94, metalness = 0, receiveShadow = true, ...meshProps }: ProceduralGroundProps): React.JSX.Element — ⚠ undocumented - `ProceduralGroundProps` (interface): interface ProceduralGroundProps extends Omit — ⚠ undocumented - `ProceduralTerrainConfig` (interface): interface ProceduralTerrainConfig — ⚠ undocumented @@ -1253,7 +1263,9 @@ - `TerraformBrushCursor` (function): function TerraformBrushCursor({ center, y = 0.05, radius, mode }: TerraformBrushCursorProps): React.JSX.Element | null — ⚠ undocumented - `TerraformBrushCursorProps` (interface): interface TerraformBrushCursorProps — ⚠ undocumented - `TerrainArea` (type): type TerrainArea = number | readonly [width: number, depth: number] — ⚠ undocumented +- `TerrainField` (interface): interface TerrainField — A sampleable ground surface: height and normal at any x/z, with optional bounds and water level. - `TerrainHeightSampler` (type): type TerrainHeightSampler = (x: number, z: number) => number — ⚠ undocumented +- `TerrainNormal` (type): type TerrainNormal = readonly [number, number, number] — A surface normal vector at a terrain sample point. - `TerrainSeed` (type): type TerrainSeed = number | string — ⚠ undocumented - `TerrainVertexColorOptions` (interface): interface TerrainVertexColorOptions — ⚠ undocumented @@ -1265,6 +1277,7 @@ - `EditableGround` (function): function EditableGround({ terrain, bounds, segments = 96, version = 0, baseColor = "#3f6b3a", surfaceColors = DEFAULT_SURFACE_COLORS, }: EditableGroundProps): React.JSX.Element — ⚠ undocumented - `EditableGroundProps` (interface): interface EditableGroundProps — ⚠ undocumented - `FieldGroundOptions` (interface): interface FieldGroundOptions — ⚠ undocumented +- `FractalNoiseConfig` (interface): interface FractalNoiseConfig — Octave settings for {@link fractalNoise}: frequency, layering, and optional ridged shaping. - `GrassBladeGeometryOptions` (interface): interface GrassBladeGeometryOptions — ⚠ undocumented - `GrassField` (function): function GrassField({ count = DEFAULT_GRASS_COUNT, density = DEFAULT_GRASS_DENSITY, budget, area = 40, seed = 1, segments = 4, bladeHeight, bladeWidth, bladeBend, heightAt, colorBase, colorTip, colorVariation, wind, roughness, castShadow = false, receiveShadow = true, frustumCulled = true, ...meshPr… — ⚠ undocumented - `GrassFieldProps` (interface): interface GrassFieldProps extends Omit — ⚠ undocumented @@ -1273,6 +1286,7 @@ - `GrassRange` (type): type GrassRange = number | readonly [min: number, max: number] — ⚠ undocumented - `GrassShaderUniforms` (interface): interface GrassShaderUniforms — ⚠ undocumented - `GrassWindOptions` (interface): interface GrassWindOptions — ⚠ undocumented +- `NoiseFieldConfig` (interface): interface NoiseFieldConfig — Configuration for {@link noiseField}: seed, amplitude, and fractal noise shaping. - `ProceduralGround` (function): function ProceduralGround({ terrain, colors, roughness = 0.94, metalness = 0, receiveShadow = true, ...meshProps }: ProceduralGroundProps): React.JSX.Element — ⚠ undocumented - `ProceduralGroundProps` (interface): interface ProceduralGroundProps extends Omit — ⚠ undocumented - `ProceduralTerrainConfig` (interface): interface ProceduralTerrainConfig — ⚠ undocumented @@ -1282,7 +1296,9 @@ - `TerraformBrushCursor` (function): function TerraformBrushCursor({ center, y = 0.05, radius, mode }: TerraformBrushCursorProps): React.JSX.Element | null — ⚠ undocumented - `TerraformBrushCursorProps` (interface): interface TerraformBrushCursorProps — ⚠ undocumented - `TerrainArea` (type): type TerrainArea = number | readonly [width: number, depth: number] — ⚠ undocumented +- `TerrainField` (interface): interface TerrainField — A sampleable ground surface: height and normal at any x/z, with optional bounds and water level. - `TerrainHeightSampler` (type): type TerrainHeightSampler = (x: number, z: number) => number — ⚠ undocumented +- `TerrainNormal` (type): type TerrainNormal = readonly [number, number, number] — A surface normal vector at a terrain sample point. - `TerrainSeed` (type): type TerrainSeed = number | string — ⚠ undocumented - `TerrainVertexColorOptions` (interface): interface TerrainVertexColorOptions — ⚠ undocumented @@ -1384,7 +1400,7 @@ - `SensorReadoutMeter` (function): function SensorReadoutMeter({ label, reading, className }: SensorReadoutMeterProps): React.JSX.Element — A handheld-sensor readout: needle strength bar + the raw reading, or a "no signal" idle state. - `SensorReadoutMeterProps` (interface): interface SensorReadoutMeterProps — ⚠ undocumented -- `useHiddenStateProbe` (function): function useHiddenStateProbe(origin: EntityPosition, sources: readonly HiddenStateSource[], options: SensorProbeOptions): any — Reads a hidden zone/entity state variable in range (EMF / thermometer / geiger style sensor verb, #116). +- `useHiddenStateProbe` (function): function useHiddenStateProbe(origin: EntityPosition, sources: readonly HiddenStateSource[], options: SensorProbeOptions): SensorReading | null — Reads a hidden zone/entity state variable in range (EMF / thermometer / geiger style sensor verb, #116). ## @jgengine/shell/vision/RevealVision