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-combat/SKILL.md b/.claude/skills/jgengine-combat/SKILL.md index 9efbde7ce..162a7b332 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/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-gameplay/SKILL.md b/.claude/skills/jgengine-gameplay/SKILL.md index 3a3f21333..1ef4c52ed 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. --- @@ -154,10 +154,6 @@ Offline↔cloud is the same one-line swap as `createSaveStore`: the shell can in -## 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 f00ee3216..acdd708f9 100644 --- a/.claude/skills/jgengine-gameplay/api.md +++ b/.claude/skills/jgengine-gameplay/api.md @@ -989,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 4b019de84..5945873b6 100644 --- a/.claude/skills/jgengine-gameplay/capabilities.md +++ b/.claude/skills/jgengine-gameplay/capabilities.md @@ -16,6 +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"` +## decay-meter — survival meters that drain/refill over game time (hunger, water, oxygen, stamina) + +- `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 - `createGameDialogue` (function) · `import { createGameDialogue } from "@jgengine/core/game/dialogue"` @@ -59,6 +62,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"` diff --git a/.claude/skills/jgengine-multiplayer/SKILL.md b/.claude/skills/jgengine-multiplayer/SKILL.md index d39ef3b54..55256febe 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. --- @@ -88,7 +88,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/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 de06b25cb..000000000 --- a/.claude/skills/jgengine-procedural/SKILL.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: jgengine-procedural -description: Procedural API: seeded gen, terrain, voxels, buildings, simulation worlds. ---- - -# jgengine-procedural - -**Import from the curated barrel** `@jgengine/core/procedural` (stable, re-exports this domain's public API) — deep paths `@jgengine/core//` still work for anything not re-exported. - -## 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-world/SKILL.md b/.claude/skills/jgengine-world/SKILL.md index 623255747..d81eb520a 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/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 223a72e2e..192681a70 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.md b/CLAUDE.md index 21e8709cf..a5c71628b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,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-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 6f7bf7f5b..cc644bc81 100644 --- a/scripts/check-skill-sync.ts +++ b/scripts/check-skill-sync.ts @@ -1,6 +1,8 @@ -import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } 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"],