@jgengine/react 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -34,6 +34,68 @@ At publish, rename this heading to the new version and mirror the entries into
34
34
 
35
35
  ### Fixed
36
36
 
37
+ ## 0.17.0
38
+
39
+ ### Migrate
40
+
41
+ - **Bump lockstep SDK packages to `^0.17.0`:** `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.14.0`; `@jgengine/github` is `0.4.0`.
42
+ - **A deleted player profile stays deleted.** When a game persists players (`save.scope` includes `player`), hydrating a member with no `jgPlayerProfiles` row no longer falls back to the copy of their state kept on the server row — that copy was resurrected and then written back as authoritative, so a world reset that cleared profiles but not servers silently restored every player's old balance. A game whose save scope excludes players (`"none"`, `"chunks"`) is unchanged: the server-row copy is still their state. If you seed members onto a server row without going through `joinServer`, write their profile row too.
43
+ - **`slotsPerServer` is capped at 256** (`JG_MAX_MEMBERS_PER_SERVER`). `createGameServerFunctions` now throws at wiring time on a larger value instead of accepting it and failing at the write that overflows the document. One server is one Convex document holding the world row, the roster, and every member's session state; a bigger number was never a bigger world. Shard across servers, or move world state into `jgWorldChunks`.
44
+ - **The tick cron only sweeps games that declare `loop.onTick`.** A game with no tick hook used to pay a full hydrate plus a full persist every second per running server, for a snapshot `runtime.tick` returned unchanged; it now costs nothing while idle. If you relied on the tick cron to autosave a hookless game, the 60s `flushDirtyServers` cron and the `flushSave` mutation still do it.
45
+
46
+ ### Changed
47
+
48
+ - **Walking NPCs now stop at the geometry that stops the player.** `wander`, `patrol`, and `ctx.scene.entity.moveToward` consulted no obstacles at all, so a pedestrian walked through the building the player cannot enter. All three now slide their step against the scene's blocking physical colliders — the same query `stepPlayerMovement` reads. A patrol's path state still advances on its own clock (an authored route is not rewound by contact), but the entity can no longer end a tick inside a solid. Pass `avoidSolids: false` to `moveToward` for a mover meant to ignore the world (a flier, a camera rig).
49
+ - **`@jgengine/assets` verify fails a model source that contributes no index entries.** A model pack that has never been pulled is a pin nothing has ever checked — that is how a Street Pack archive sat in the catalogue titled "Downtown City MegaKit". Record why a pack is not pulled in the new `AssetSource.unpulled` string; materials and sprites pull on demand and are exempt.
50
+ - **`persistServerSnapshot` returns `boolean` and skips a write that would change nothing.** Every subscriber of a query that read a server row re-receives the whole result when that document changes, so an unconditional write on the 1 Hz tick re-pushed the entire world to every client once a second whether or not anything moved. `ServerPersistPlan` gains `changed` (`serverState` / `sessionPlayers` / `revision` / `any`) for hosts that write field-by-field.
51
+ - **`sessionPlayers` is merged over the stored map, not rebuilt from the snapshot**, and entries for non-members are pruned. This is what makes a partially hydrated snapshot safe to persist.
52
+ - **`tickActiveServers` is bounded** to `maxServersPerTick` (default 32) servers per transaction, oldest tick anchor first, instead of pulling every running server of every game into one transaction. `flushDirtyServers` is bounded the same way.
53
+
54
+ ### Added
55
+
56
+ - **`solidObstaclesNear` / `resolveWalkerStep` (`@jgengine/core/movement/solidObstacles`)** — the engine's single answer to "what is solid here", now shared rather than re-derived per consumer. `solidObstaclesNear(ctx, position, reachX, reachZ)` returns the blocking physical colliders near a point through a bounded `inBox` broadphase; `resolveWalkerStep(ctx, position, stepX, stepZ)` slides a horizontal step against them with the player's axis-separated sliding. Reach for these instead of deriving obstacles from a game catalog's own solidity flags.
57
+ - **Bounded hydration and a client chunk read** — `loadServerSnapshot(ctx, server, runtime, { players, chunkKeys })` hydrates only the members and chunks a mutation will touch instead of a document read per member plus one per chunk, and the generated `getChunks` query lets a client read chunk state by key (capped at `MAX_CHUNKS_PER_QUERY`). Chunks were the documented escape hatch for world state too large for one row, but reads were unbounded and clients could not see them at all, so moving geometry there made things worse. `getServerMeta` returns lobby state (status, member count, revision) without the world payload.
58
+ - **`@jgengine/core/runtime/worldChunks`** — `chunkKeyAt` / `chunkCoordAt` / `chunkKeyOf` / `parseChunkKey` / `chunkKeysInRadius` at a declared cell size, plus `setChunk` / `updateChunk` / `deleteChunk`, which mark the key dirty so the host actually persists it. `chunkKey` was an opaque game-chosen string and the engine never wrote `dirty.chunks`, so every game reimplemented the coordinate mapping and a game that forgot to push a key silently never saved that chunk.
59
+ - **Placed objects cull to `replication.aoiRadius`.** `objects` is the one large spatial replicated set and had no projector, so a world with a few thousand placements sent all of them to every viewer. It now joins the same area-of-interest policy as `entities` and `stats`. Unset `aoiRadius` still replicates everything.
60
+ - **`createGameServerFunctions({ matchmaking: "singleton" })`** — one shared world per game: auto-match always targets the existing world and a join past capacity fails loudly, instead of quietly creating a second world the player would be alone in. `joinServer` also reconciles a stored `slotsPerServer`/`save` that has drifted from the host options, which previously made raising the option a no-op on worlds that already existed. `selectJoinTarget` (`@jgengine/core/runtime/hostPolicy`) is the pure decision behind it.
61
+ - **`GameRuntime.hasTick`** reports whether a runtime declares `loop.onTick`, so any host's scheduler can skip a server whose tick is a no-op by construction.
62
+
63
+ ### Removed
64
+
65
+ - **Asset source `quaternius-downtown-city`.** It was titled "Downtown City MegaKit" but pinned at the Street Pack archive, which ships Blends/FBX/OBJ only — a pull found no models. Nothing referenced it.
66
+
67
+ ## 0.16.0
68
+
69
+ ### Migrate
70
+
71
+ - **Bump lockstep SDK packages to `^0.16.0`:** `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.13.0`; `@jgengine/github` is `0.3.0`.
72
+ - **Placed objects now replicate.** `objects` joined the always-on baseline snapshot modules, so `ctx.hydrate` replaces the client's placed object set with the host's, the way `entities` already did. A game whose peers all run the same authored placement is unaffected; a game that places props on the client only, after hydrate, must move that placement to the host or keep it out of `ctx.scene.object`.
73
+
74
+ ### Changed
75
+
76
+ - **`GeneratedBuilding`'s `kit` prop is now the `BuildingKit`**; the per-part React escape hatch it used to name moved to `partRenderer`, so `kit` means the same thing on `GeneratedBuilding` and `InstancedBuildings`. Rename the prop if you passed a `BuildingKitRenderer`.
77
+
78
+ ### Added
79
+
80
+ - **`modelLoadFallbacks()` from `@jgengine/shell/render/modelLoad`** lists every model the shared loader diagnosed as broken and replaced with a magenta placeholder. A placeholder was only ever a console warning, so a shipped game could render one in its starting area and nobody found out until someone looked at a screenshot; a capture host or smoke test can now require the list to be empty. `clearModelLoadFallbacks()` resets it between runs.
81
+ - **`worldHealthBars.occlude` / `WorldHealthBars`+`WorldNameplates` `occlude` prop.** World bars and nameplates are a screen-space overlay drawn outside the WebGL depth buffer, so nothing in the scene can hide them and an enemy's health bar reads straight through the building it stands behind. Set `occlude: true` to drop a bar when world geometry blocks the line from the player to the entity. Default `false` preserves the always-visible behaviour every shipped game currently relies on.
82
+
83
+ - **`DustField` + `WeatherLayer` mode `"dust"` (`@jgengine/shell/weather`) — wind-borne particulate.** `WeatherLayer` could only do `clear`/`rain`/`snow`/`mixed`, so an arid, volcanic, ashen, or blown-out world had no way to put anything in the air. Dust drifts on horizontal wind with a slow vertical bob rather than falling; `groundBias` packs density toward the surface, motes fade near the top of the volume so recycled ones do not pop against the sky, and faster motes ride higher. Set `dustAlways` to keep it in the air alongside rain or snow instead of replacing them. Defaults `DEFAULT_DUST_COUNT`/`DEFAULT_DUST_DENSITY` sit beside the rain and snow pair.
84
+
85
+ - **Authored `city` volumes render their near-LOD facades with a `BuildingKit`.** `CityRules.buildingKit` names a kit; a game registers the bound art with `registerBuildingKit(kit)` from `@jgengine/shell/structures/buildingKitRegistry` and the scene document carries only the name, so a city stays portable and free of model paths. Unset or unknown names keep the palette blocks. This puts the whole `city` primitive — not just the `building()` feature — on the kit path.
86
+ - **`BuildingKit` from `@jgengine/core/world/buildingKit`** binds a generated building's facade part slots to real models, so procedural buildings render as an instanced asset kit instead of untextured blocks. `defineBuildingKit({ parts: { wall: ["Wall_A.glb", …] } })` maps a part kind — or a `${facade}.${kind}` slot — to ordered variants that `kit.variant` indexes, with `fit`/`offset`/`rotation`/`scale`/`tint`/`autoOrient` per variant. Kinds a kit leaves unbound keep rendering the palette block, so a partial kit is a valid kit, and `omit` drops a kind entirely. Pass one through `building({ kit })` or straight to `InstancedBuildings`; `buildingKitVariantCounts(kit)` feeds `generateBuilding` so its seeded picks span exactly the models the kit binds.
87
+ - **`generateBuilding`, `generateBuildingDistrict`, and `createBuildingConfig` are now documented public API** (`@jgengine/core/world/buildings`) and appear in `capabilities.md` under `building-generator`.
88
+ - **`capture.views` on `defineGame`** — named framings (`GameCaptureView`: `look`, `lookFrom`, `spawn`, `state`, `run`, `settleMs`) that `shoot <game> --view <name>` and `drive --view <name>` replay exactly, so a before/after pair is the same view by construction. `shoot <game> --list-views` prints what a game declares; an unknown name fails listing them, and any explicit capture flag still wins over the view's value.
89
+ - **`modelLoadIdleMs()` from `@jgengine/shell/render/modelLoad`** reports how long the shared GLB loader has been idle (`0` while a model is in flight). Capture hosts wait on it instead of guessing a settle delay, so an establishing shot no longer races streaming models.
90
+ - **Per-instance state on placed objects** — `SceneObject.state` (opaque `Record`), set through `ctx.scene.object.setState`/`patchState`/`place({ state })`. It survives move/rotate/setVisual, replicates and saves with the object, and round-trips as `RuntimeObjectRow.flags` via `toRuntimeObjectRow`/`fromRuntimeObjectRow` (`@jgengine/core/multiplayer`). A chest's locked flag or a machine's fill level no longer needs a game-owned map keyed by `instanceId`.
91
+ - **`slotInventory` is a working feature** — `ctx.scene.object.slots` gives objects whose catalog entry declares it real per-instance container contents: `layout`/`state`/`count`, `put`/`take`, and `transferIn`/`transferOut` between a player inventory and the object, all validated against the declared slot count, `accepts` kind filter, and stack limits, with neither side mutated on a rejection. Contents live on `SceneObject.slots`, so they replicate and save with the placement.
92
+ - **`loadServerSnapshot` and `persistServerSnapshot` are public** (`@jgengine/convex/server`), and `createGameServerFunctions` also returns `helpers` (`loadSnapshot`, `applyCommand`, `persistSnapshot`, `runCommand`) bound to the same runtimes and auth mode. A host mutation can now pair snapshot work with its own table writes in one transaction; the registered `runCommand` mutation delegates to `helpers.runCommand`, so there is one implementation.
93
+
94
+ ### Fixed
95
+
96
+ - **Open storefronts are no longer holes through the building.** `generateBuilding` emitted an open ground-floor store as a lone `storefront` panel with no wall behind it, while every upper-floor window was correctly backed by one. Storefront and window materials render at 0.56 opacity and a generated building is a hollow shell, so those bays let you see straight through the building to the world beyond — and open stores only land on the street-facing facade, so a row of shops along a road was the worst case. The bay now emits its wall first, exactly as an upper floor does.
97
+ - **`joinServer`, `flushSave`, `flushDirtyServers`, and the `auto` autosave can write player profiles again.** All four cleared the snapshot's dirty set before handing it to the persist plan, which gates profile writes on exactly that set — so the functions named "flush" flushed the server row and chunks only, and an `onNewPlayer` grant (starting money, starting items) never reached `jgPlayerProfiles`. `joinServer` now persists the snapshot it just marked; the flush paths mark every player dirty first, since hydrating from storage resets the dirty set. `planServerPersist` also clears `dirtyAt` once everything outstanding is written, so a successful flush stops autosave from re-firing forever, and a profile patch no longer clobbers a host-maintained `dirtyAt`.
98
+
37
99
  ## 0.15.0
38
100
 
39
101
  ### Migrate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/react",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "React UI layer for JGengine: GameProvider, hooks, and headless primitives over @jgengine/core.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "test": "bun test src"
32
32
  },
33
33
  "dependencies": {
34
- "@jgengine/core": "^0.15.0"
34
+ "@jgengine/core": "^0.17.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "react": "^19.0.0",
@@ -6,7 +6,7 @@
6
6
 
7
7
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
8
8
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
9
- - `VERSION` (const): const VERSION: "0.15.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.17.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -228,7 +228,7 @@
228
228
 
229
229
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
230
230
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
231
- - `VERSION` (const): const VERSION: "0.15.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
231
+ - `VERSION` (const): const VERSION: "0.17.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
232
232
 
233
233
  ## @jgengine/core/runtime/adapter
234
234
 
@@ -264,6 +264,13 @@
264
264
  - `CommandValidationError` (type): type CommandValidationError = { reason: string } — ⚠ undocumented
265
265
  - `RunCommandResult` (type): type RunCommandResult = | { ok: true; snapshot: GameRuntimeSnapshot } | { ok: false; reason: string } — ⚠ undocumented
266
266
 
267
+ ## @jgengine/core/runtime/context/objectSlots
268
+
269
+ - `ObjectSlotRejection` (type): type ObjectSlotRejection = | "unknown-object" | "no-slot-inventory" | "unknown-inventory" | "no-space" | "wrong-kind" | "slot-occupied" | "invalid-slot" | "insufficient" — Why an object slot mutation was refused — the instance, its catalog entry, the player inventory, or the declared `accepts`/capacity.
270
+ - `ObjectSlotResult` (type): type ObjectSlotResult = { status: "ok" } | { status: "rejected"; reason: ObjectSlotRejection } — Outcome of an object slot mutation: applied, or refused with a reason and no state change on either side.
271
+ - `ObjectSlotTransfer` (interface): interface ObjectSlotTransfer — Which player inventory a transfer moves between, and what it moves.
272
+ - `SceneObjectSlots` (interface): interface SceneObjectSlots — Per-instance container contents for placed objects whose catalog entry declares `slotInventory` — the engine side of "install a GPU into a rack", "put an item in a chest". Contents live on `SceneObject.slots`, so they replicate and save with the object rather than needing a game-owned table keyed by `instanceId`, and every mutation is validated against the declared `accepts` and stack limits.
273
+
267
274
  ## @jgengine/core/runtime/gameContext
268
275
 
269
276
  - `CatalogEntityRole` (type): type CatalogEntityRole = "player" | "enemy" | "hostile" | "npc" | "vehicle" — ⚠ undocumented
@@ -327,7 +334,7 @@
327
334
 
328
335
  ## @jgengine/core/runtime/gameRuntime
329
336
 
330
- - `GameRuntime` (type): type GameRuntime = { gameId: string; save: SaveConfig; hydrate: (input: HydrateInput) => GameRuntimeSnapshot; runCommand: ( snapshot: GameRuntimeSnapshot, actorUserId: string, commandName: string, input: unknown, ) => ReturnType<typeof runCommand>; tick: (snapshot: GameRuntimeSnapshot, dtSeconds: nu… — ⚠ undocumented
337
+ - `GameRuntime` (type): type GameRuntime = { gameId: string; save: SaveConfig; /** * Whether this runtime declares `loop.onTick`. A host's tick cron reads it to skip hydrating and * persisting a server whose `tick` is a no-op by construction — the difference between a world that * costs a full read/write every second while… — ⚠ undocumented
331
338
  - `GameRuntimeDefinition` (type): type GameRuntimeDefinition = { gameId: string; save: SaveConfig; commands: Record<string, CommandDef>; loop?: ServerLoopHooks; } — ⚠ undocumented
332
339
  - `HydrateInput` (type): type HydrateInput = { gameId: string; serverId: string; serverRow: RuntimeServerRow; playersByUserId: Record<string, RuntimePlayerRow>; chunksByKey: Record<string, RuntimeChunkRow>; revision?: number; } — ⚠ undocumented
333
340
  - `RuntimeInitContext` (type): type RuntimeInitContext = { snapshot: GameRuntimeSnapshot; setSnapshot: (snapshot: GameRuntimeSnapshot) => void; } — ⚠ undocumented
@@ -358,21 +365,28 @@
358
365
  - `OPEN_SERVER_LISTING_MAX` (const): const OPEN_SERVER_LISTING_MAX: 100 — ⚠ undocumented
359
366
  - `PlayerProfileRecord` (type): type PlayerProfileRecord = { userId: string; gameId: string; playerState: RuntimePlayerRow; revision: number; updatedAt: number; } — ⚠ undocumented
360
367
  - `ServerListing` (type): type ServerListing = { serverId: string; status: GameServerStatus; memberCount: number; slotsPerServer: number; mode?: string; label?: string; visibility?: SessionVisibility; joinCode?: string; tags?: string[]; updatedAt: number; } — ⚠ undocumented
361
- - `ServerPersistPlan` (type): type ServerPersistPlan = { server: GameServerRecord; profiles: PlayerProfileRecord[]; chunks: WorldChunkRecord[]; deletedChunks: string[]; leaderboard: LeaderboardIncrement[]; } — ⚠ undocumented
368
+ - `ServerPersistPlan` (type): type ServerPersistPlan = { server: GameServerRecord; profiles: PlayerProfileRecord[]; chunks: WorldChunkRecord[]; deletedChunks: string[]; leaderboard: LeaderboardIncrement[]; /** * Which fields of the server row the plan actually moved. A store that pays per written field — * or whose subscribers r… — ⚠ undocumented
362
369
  - `SessionAttributes` (type): type SessionAttributes = { label?: string; mode?: string; visibility?: SessionVisibility; joinCode?: string; tags?: string[]; } — ⚠ undocumented
363
370
  - `SessionVisibility` (type): type SessionVisibility = "public" | "private" — ⚠ undocumented
364
371
  - `ToServerListingOptions` (type): type ToServerListingOptions = { includeJoinCode?: boolean; } — ⚠ undocumented
365
372
  - `WorldChunkRecord` (type): type WorldChunkRecord = { serverId: string; chunkKey: string; snapshot: RuntimeChunkRow; updatedAt: number; } — ⚠ undocumented
373
+ - `isSnapshotClean` (function): function isSnapshotClean(snapshot: GameRuntimeSnapshot): boolean — True when a snapshot carries no unwritten mutation — nothing for a persist to do.
366
374
 
367
375
  ## @jgengine/core/runtime/hostPolicy
368
376
 
377
+ - `JG_MAX_MEMBERS_PER_SERVER` (const): const JG_MAX_MEMBERS_PER_SERVER: 256 — Hard ceiling on `slotsPerServer` for a single hosted server row.
378
+ - `JoinCandidate` (type): type JoinCandidate = { memberUserIds: readonly string[]; slotsPerServer: number; visibility?: SessionVisibility | undefined; createdAt: number; } — A row `selectJoinTarget` can choose between — the matchmaking-relevant fields of a server record.
379
+ - `JoinTarget` (type): type JoinTarget<T extends JoinCandidate> = | { kind: "join"; row: T } | { kind: "create" } | { kind: "refuse"; reason: string } — What a join should do with the candidate rows it found.
380
+ - `MatchmakingMode` (type): type MatchmakingMode = "auto" | "singleton" — How auto-match behaves when no `serverId` is supplied.
369
381
  - `canJoinPrivateServer` (function): function canJoinPrivateServer(args: { isMember: boolean; joinCode: string | undefined; suppliedCode: string | undefined; }): boolean — Private-server join-code gate. Existing members always pass; non-members must present a matching `joinCode` (loose-normalized via {@link normalizeJoinCode}). Callers still decide whether the server is private — this only answers the code/membership half.
370
382
  - `isAutoJoinCandidate` (function): function isAutoJoinCandidate(args: { memberUserIds: readonly string[]; slotsPerServer: number; visibility: SessionVisibility | undefined; userId: string; }): boolean — Auto-match candidate when no `serverId` is supplied: already a member, or a public room with free capacity. Private rooms are never auto-picked (join-by-code / direct id only).
371
383
  - `isListablePublicly` (function): function isListablePublicly(visibility: SessionVisibility | undefined): boolean — True when a server's `visibility` should surface in public listings / browse results.
372
384
  - `isPrivateJoinBlocked` (function): function isPrivateJoinBlocked(args: { visibility: SessionVisibility | undefined; memberUserIds: readonly string[]; userId: string; joinCode: string | undefined; suppliedCode: string | undefined; }): boolean — Whether a private-visibility server blocks this join (non-member without a matching code). Public / undefined visibility never blocks.
373
385
  - `isServerFull` (function): function isServerFull(memberUserIds: readonly string[], slotsPerServer: number, userId: string): boolean — True when the server has no free slots for a non-member. Existing members never count as "full" so rejoin/leave cycles keep working.
374
386
  - `isServerMember` (function): function isServerMember(memberUserIds: readonly string[], userId: string): boolean — Whether `userId` is already on the server's member roster.
387
+ - `selectJoinTarget` (function): function selectJoinTarget<T extends JoinCandidate>(rows: readonly T[], args: { userId: string; mode?: MatchmakingMode; /** Capacity to judge rows against — the host's current option, when it supersedes the stored value. */ slotsPerServer?: number; }): JoinTarget<T> — Resolve an auto-match join against the game's joinable rows under a {@link MatchmakingMode}. Reach for it instead of hand-rolling the "find a room with space, else make one" scan, which is where a singleton world silently shards into per-player copies.
375
388
  - `statusAfterLeave` (function): function statusAfterLeave(remainingMemberCount: number, currentStatus: GameServerStatus): GameServerStatus — Status after a leave: empty rooms reopen; non-empty rooms keep their current status.
389
+ - `validateSlotsPerServer` (function): function validateSlotsPerServer(slotsPerServer: number): { ok: true } | { ok: false; reason: string } — Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold.
376
390
  - `withJoinedMember` (function): function withJoinedMember(memberUserIds: readonly string[], userId: string): string[] — Roster after a successful join: unchanged when already a member, else appended.
377
391
  - `withoutMember` (function): function withoutMember(memberUserIds: readonly string[], userId: string): string[] — Roster after a leave: `userId` removed; order of remaining members preserved.
378
392
 
@@ -402,6 +416,11 @@
402
416
  - `MotionIntentBatch` (interface): interface MotionIntentBatch — ⚠ undocumented
403
417
  - `MotionIntents` (interface): interface MotionIntents — Seam for game code to reach the motion the shell's FrameDriver otherwise owns privately (#162.4). Game code calls `impulse`, `pushHorizontal`, `setVerticalVelocity`, and/or `setY` from `onTick` or commands; the shell calls `takePending()` once per frame, before integrating gravity, to drain what accumulated. `setY` wins over physics for that frame; impulses add to the velocity the driver is about to integrate; a later `setVerticalVelocity` replaces that velocity outright. Horizontal pushes compose with the walk controller (#282.4): they add to its horizontal velocity and decay naturally as it re-blends toward input — knockback, dashes, explosion shoves without raw `setPose` offsets.
404
418
 
419
+ ## @jgengine/core/runtime/objectRows
420
+
421
+ - `fromRuntimeObjectRow` (function): function fromRuntimeObjectRow(row: RuntimeObjectRow): SceneObject — Inverse of {@link toRuntimeObjectRow}: rebuild the live placed object a host persisted.
422
+ - `toRuntimeObjectRow` (function): function toRuntimeObjectRow(object: SceneObject): RuntimeObjectRow — Convert a live placed object into its persisted row — the bridge between `ctx.scene.object` and `GameRuntimeSnapshot.server.objects`/`chunks[].objects`. Per-instance `state` is carried as `flags`. Presentation overrides (`visual`, `animation`) are catalog- and authoring-resolved and deliberately stay off the persisted row — put anything a command must own in `state`.
423
+
405
424
  ## @jgengine/core/runtime/perContext
406
425
 
407
426
  - `perContext` (function): function perContext<T>(init: (ctx: GameContext) => T): (ctx: GameContext) => T — Per-`GameContext` lazy state, keyed by context identity through a `WeakMap` so it is reclaimed when the context is — the seam a game uses instead of a module-global `Map`, which is process-global and bleeds across worlds when one host serves several `serverId`s (#632). `init` runs once per distinct context on first access; the same context always resolves to the same value. This mirrors the engine's built-in per-ctx pattern (`ctx.player.motion`, `stepPlayerMovement`).
@@ -441,7 +460,7 @@
441
460
  - `RuntimeChunkRow` (type): type RuntimeChunkRow = { chunkKey: string; objects: RuntimeObjectRow[]; entities: RuntimeEntityRow[]; flags?: Record<string, unknown>; } — ⚠ undocumented
442
461
  - `RuntimeEntityRow` (type): type RuntimeEntityRow = { instanceId: string; catalogId: string; position?: [number, number, number]; rotationY?: number; parentSpace?: string; group?: string; stats?: Record<string, { current: number; max: number; min?: number }>; targetInstanceId?: string | null; userId?: string; } — ⚠ undocumented
443
462
  - `RuntimeInventorySlot` (type): type RuntimeInventorySlot = { item: string; count: number; slot?: number; } — ⚠ undocumented
444
- - `RuntimeObjectRow` (type): type RuntimeObjectRow = { instanceId: string; catalogId: string; position: [number, number, number]; rotationY?: number; parentSpace?: string; flags?: Record<string, unknown>; } — ⚠ undocumented
463
+ - `RuntimeObjectRow` (type): type RuntimeObjectRow = { instanceId: string; catalogId: string; position: [number, number, number]; rotationY?: number; parentSpace?: string; /** Opaque per-instance state; the persisted name for `SceneObject.state`. */ flags?: Record<string, unknown>; /** Container contents for a catalog `slotInve… — The persisted form of a placed `SceneObject`: identity, placement, per-instance state, and slot contents. Convert with `toRuntimeObjectRow`/`fromRuntimeObjectRow` (`runtime/objectRows`).
445
464
  - `RuntimePlayerRow` (type): type RuntimePlayerRow = { userId: string; inventories: Record<string, RuntimeInventorySlot[]>; economy: Record<string, number>; unlocks: string[]; quests?: unknown; social?: unknown; leaderboard?: Record<string, number>; session?: Record<string, unknown>; } — ⚠ undocumented
446
465
  - `RuntimeProfileRow` (type): type RuntimeProfileRow = { userId: string; gameId: string; player: RuntimePlayerRow; updatedAt: number; } — ⚠ undocumented
447
466
  - `RuntimeServerRow` (type): type RuntimeServerRow = { entities: RuntimeEntityRow[]; objects: RuntimeObjectRow[]; session: Record<string, unknown>; feeds?: Record<string, unknown[]>; } — ⚠ undocumented
@@ -483,6 +502,20 @@
483
502
  - `WorldHostConnection` (interface): interface WorldHostConnection — One client's link on the host side: routes its upstream frames into the shared session, pushes it sync frames.
484
503
  - `WorldServerFrame` (type): type WorldServerFrame = | { t: "baseline"; revision: number; snapshot: WorldSnapshot } | { t: "diff"; diff: WorldDiff } — Host→client frame: the full baseline a joiner needs, then per-tick diffs. What a transport marshals downstream.
485
504
 
505
+ ## @jgengine/core/runtime/worldChunks
506
+
507
+ - `ChunkCoord` (type): type ChunkCoord = { cx: number; cz: number } — Integer cell coordinates of a chunk on the ground plane.
508
+ - `DEFAULT_CHUNK_SIZE` (const): const DEFAULT_CHUNK_SIZE: 64 — Default edge length, in world units, of one persisted world chunk.
509
+ - `chunkCoordAt` (function): function chunkCoordAt(position: readonly [number, number, number], size: number = DEFAULT_CHUNK_SIZE): ChunkCoord — Chunk coordinates containing a world position at `size` units per cell.
510
+ - `chunkKeyAt` (function): function chunkKeyAt(position: readonly [number, number, number], size: number = DEFAULT_CHUNK_SIZE): string — Chunk key containing a world position at `size` units per cell.
511
+ - `chunkKeyOf` (function): function chunkKeyOf(coord: ChunkCoord): string — Chunk key for a cell — the canonical `"cx,cz"` string a `jgWorldChunks` row is stored under. Games used to invent this mapping each time, which made two systems in one game disagree about which row a position belongs to.
512
+ - `chunkKeysInRadius` (function): function chunkKeysInRadius(center: readonly [number, number, number], radius: number, size: number = DEFAULT_CHUNK_SIZE): string[] — Every chunk key whose cell intersects a radius around a world position — the key list a viewer's bounded hydration or client chunk query asks for, instead of loading a whole world.
513
+ - `createEmptyChunkRow` (function): function createEmptyChunkRow(chunkKey: string): RuntimeChunkRow — An empty chunk row for `chunkKey` — the starting value for a cell nothing has been placed in yet.
514
+ - `deleteChunk` (function): function deleteChunk(snapshot: GameRuntimeSnapshot, chunkKey: string): GameRuntimeSnapshot — Drop a chunk from a snapshot and mark the key dirty so the host deletes its stored row.
515
+ - `parseChunkKey` (function): function parseChunkKey(key: string): ChunkCoord | null — Parse a `chunkKeyOf` string back to coordinates; `null` for anything not in that form.
516
+ - `setChunk` (function): function setChunk(snapshot: GameRuntimeSnapshot, chunkKey: string, chunk: RuntimeChunkRow): GameRuntimeSnapshot — Write a chunk into a snapshot and mark it dirty, so the next persist actually stores it. The engine never writes `dirty.chunks` for a raw `snapshot.chunks[key] = …` assignment, so a command that mutated the map by hand silently never saved that chunk.
517
+ - `updateChunk` (function): function updateChunk(snapshot: GameRuntimeSnapshot, chunkKey: string, update: (chunk: RuntimeChunkRow) => RuntimeChunkRow): GameRuntimeSnapshot — Read-modify-write a chunk (creating it empty when absent), marking it dirty.
518
+
486
519
  ## @jgengine/core/runtime/worldMirror
487
520
 
488
521
  - `WorldMirror` (interface): interface WorldMirror — The client end of host-authoritative replication: folds a host's baseline + {@link WorldDiff} stream onto a local {@link GameContext}. It keeps the last full {@link WorldSnapshot}, advances it with each diff, and pushes the result through `ctx.hydrate` — so the client mirrors exactly the subsystems its own game opted into and silently ignores host modules it lacks. This is the inverse of a {@link HostedWorldSession}; the transport in between (loopback, ws, Convex) is irrelevant.
@@ -48,6 +48,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
48
48
 
49
49
  - `canJoinPrivateServer` (function) · `import { canJoinPrivateServer } from "@jgengine/core/runtime/hostPolicy"`
50
50
 
51
+ ## host-join-target — choose or refuse an auto-matched server under a matchmaking mode
52
+
53
+ - `selectJoinTarget` (function) · `import { selectJoinTarget } from "@jgengine/core/runtime/hostPolicy"`
54
+
51
55
  ## host-listing-filter — exclude private sessions from public browse results
52
56
 
53
57
  - `isListablePublicly` (function) · `import { isListablePublicly } from "@jgengine/core/runtime/hostPolicy"`
@@ -100,6 +104,18 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
100
104
 
101
105
  - `ws` (function) · `import { ws } from "@jgengine/core/runtime/adapter"`
102
106
 
107
+ ## object-slot-inventory — placed objects hold validated per-instance container contents that replicate and save with the placement
108
+
109
+ - `SceneObjectSlots` (interface) · `import { SceneObjectSlots } from "@jgengine/core/runtime/context/objectSlots"`
110
+
111
+ ## placed-object-rehydrate — rebuild live placed objects, with their state and slot contents, from persisted snapshot rows
112
+
113
+ - `fromRuntimeObjectRow` (function) · `import { fromRuntimeObjectRow } from "@jgengine/core/runtime/objectRows"`
114
+
115
+ ## placed-object-rows — a placed object's per-instance state and container contents convert to and from the persisted snapshot row a host stores
116
+
117
+ - `toRuntimeObjectRow` (function) · `import { toRuntimeObjectRow } from "@jgengine/core/runtime/objectRows"`
118
+
103
119
  ## render-fallback-diagnostics — Enable/disable the dev-only placeholder-fallback probe.
104
120
 
105
121
  - `armFallbackSeams` (function) · `import { armFallbackSeams } from "@jgengine/core/devtools/fallbackSeams"`
@@ -134,6 +150,14 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
134
150
 
135
151
  - `defineStore` (function) · `import { defineStore } from "@jgengine/shell/gameKit"`
136
152
 
153
+ ## world-chunk-key — map world positions to persisted chunk keys at a declared cell size
154
+
155
+ - `chunkKeyOf` (function) · `import { chunkKeyOf } from "@jgengine/core/runtime/worldChunks"`
156
+
157
+ ## world-chunk-write — mutate a snapshot's chunk and have the host persist it
158
+
159
+ - `setChunk` (function) · `import { setChunk } from "@jgengine/core/runtime/worldChunks"`
160
+
137
161
  ## world-convergence — derive environment coordinate content from the scene document
138
162
 
139
163
  - `environmentContentFromDocument` (function) · `import { environmentContentFromDocument } from "@jgengine/shell/gameKit"`
@@ -2190,6 +2190,11 @@
2190
2190
  - `measureLocalCollisionTriangles` (function): function measureLocalCollisionTriangles(root: THREE.Object3D, transform?: { scale: number; offset: readonly [number, number, number] }): MeasuredCollisionTriangles | null — Collect the triangle soup of the meshes under `root` in `root`'s own frame — the mesh-accurate counterpart to `measureLocalBounds`, with the same visibility/sprite/{@link MEASURE_EXCLUDE_KEY} exclusions (skinned meshes contribute their bind pose). An optional uniform `scale` + `offset` maps the result into entity-local space when the caller mounts the root scaled/translated (the model-primitive path). Returns `null` when nothing is measurable or the soup exceeds the triangle budget — callers keep their box fallback.
2191
2191
  - `reportMeasuredCollisionMesh` (function): function reportMeasuredCollisionMesh(ctx: GameContext, target: "entity" | "object", key: string, triangles: MeasuredCollisionTriangles): void — Forward measured triangles into the context (entity kind or object catalog id), deduping repeat reports of the same soup so remounts and multi-instance kinds don't rebuild the BVH.
2192
2192
 
2193
+ ## @jgengine/shell/render/modelLoad
2194
+
2195
+ - `modelLoadFallbacks` (function): function modelLoadFallbacks(): readonly { url: string; message: string }[] — Every model the shared loader diagnosed as broken and replaced with a magenta placeholder, in load order.
2196
+ - `modelLoadIdleMs` (function): function modelLoadIdleMs(): number — How long the shared GLB loader has been idle, in ms — `0` while any model is still in flight. A capture host reads this to wait for streaming to finish instead of guessing a settle delay: models that pop in after the shot are why an establishing capture used to come back half-empty until someone hand-tuned `--settle`.
2197
+
2193
2198
  ## @jgengine/shell/render/modelRender
2194
2199
 
2195
2200
  - `MaterialCache` (interface): interface MaterialCache — ⚠ undocumented
@@ -2323,10 +2328,10 @@
2323
2328
 
2324
2329
  - `BuildingBlock` (function): function BuildingBlock({ part, palette }: BuildingBlockProps): React.JSX.Element — ⚠ undocumented
2325
2330
  - `BuildingBlockProps` (interface): interface BuildingBlockProps — ⚠ undocumented
2326
- - `GeneratedBuilding` (function): function GeneratedBuilding({ building, palette, kit, visibleKinds }: GeneratedBuildingProps): React.JSX.Element — ⚠ undocumented
2331
+ - `GeneratedBuilding` (function): function GeneratedBuilding({ building, palette, partRenderer, kit, resolveModelUrl, visibleKinds, }: GeneratedBuildingProps): React.JSX.Element — ⚠ undocumented
2327
2332
  - `GeneratedBuildingProps` (interface): interface GeneratedBuildingProps — ⚠ undocumented
2328
2333
  - `InstancedBuildingPlacement` (interface): interface InstancedBuildingPlacement — ⚠ undocumented
2329
- - `InstancedBuildings` (function): function InstancedBuildings({ buildings, palette, visibleKinds }: InstancedBuildingsProps): React.JSX.Element | null — ⚠ undocumented
2334
+ - `InstancedBuildings` (function): function InstancedBuildings({ buildings, palette, kit, resolveModelUrl, visibleKinds, }: InstancedBuildingsProps): React.JSX.Element | null — ⚠ undocumented
2330
2335
  - `InstancedBuildingsProps` (interface): interface InstancedBuildingsProps — ⚠ undocumented
2331
2336
  - `PlacementGhost` (function): function PlacementGhost({ preview, height = 1, validColor = "#34d399", invalidColor = "#f87171", }: PlacementGhostProps): React.JSX.Element | null — Cursor-following build ghost: valid/invalid tint from a placement controller preview.
2332
2337
  - `PlacementGhostProps` (interface): interface PlacementGhostProps — ⚠ undocumented
@@ -2340,10 +2345,10 @@
2340
2345
 
2341
2346
  - `BuildingBlock` (function): function BuildingBlock({ part, palette }: BuildingBlockProps): React.JSX.Element — ⚠ undocumented
2342
2347
  - `BuildingBlockProps` (interface): interface BuildingBlockProps — ⚠ undocumented
2343
- - `GeneratedBuilding` (function): function GeneratedBuilding({ building, palette, kit, visibleKinds }: GeneratedBuildingProps): React.JSX.Element — ⚠ undocumented
2348
+ - `GeneratedBuilding` (function): function GeneratedBuilding({ building, palette, partRenderer, kit, resolveModelUrl, visibleKinds, }: GeneratedBuildingProps): React.JSX.Element — ⚠ undocumented
2344
2349
  - `GeneratedBuildingProps` (interface): interface GeneratedBuildingProps — ⚠ undocumented
2345
2350
  - `InstancedBuildingPlacement` (interface): interface InstancedBuildingPlacement — ⚠ undocumented
2346
- - `InstancedBuildings` (function): function InstancedBuildings({ buildings, palette, visibleKinds }: InstancedBuildingsProps): React.JSX.Element | null — ⚠ undocumented
2351
+ - `InstancedBuildings` (function): function InstancedBuildings({ buildings, palette, kit, resolveModelUrl, visibleKinds, }: InstancedBuildingsProps): React.JSX.Element | null — ⚠ undocumented
2347
2352
  - `InstancedBuildingsProps` (interface): interface InstancedBuildingsProps — ⚠ undocumented
2348
2353
  - `PlacementGhost` (function): function PlacementGhost({ preview, height = 1, validColor = "#34d399", invalidColor = "#f87171", }: PlacementGhostProps): React.JSX.Element | null — Cursor-following build ghost: valid/invalid tint from a placement controller preview.
2349
2354
  - `PlacementGhostProps` (interface): interface PlacementGhostProps — ⚠ undocumented
@@ -2362,11 +2367,11 @@
2362
2367
  - `BuildingMaterialPalette` (interface): interface BuildingMaterialPalette — ⚠ undocumented
2363
2368
  - `BuildingPartKind` (type): type BuildingPartKind = | "wall" | "window" | "awning" | "airConditioner" | "clothesline" | "storefront" | "shutter" | "storeSign" | "roof" | "roofProp" | "guardrail" | "corner" — ⚠ undocumented
2364
2369
  - `BuildingPartPlacement` (interface): interface BuildingPartPlacement — ⚠ undocumented
2365
- - `GeneratedBuilding` (function): function GeneratedBuilding({ building, palette, kit, visibleKinds }: GeneratedBuildingProps): React.JSX.Element — ⚠ undocumented
2370
+ - `GeneratedBuilding` (function): function GeneratedBuilding({ building, palette, partRenderer, kit, resolveModelUrl, visibleKinds, }: GeneratedBuildingProps): React.JSX.Element — ⚠ undocumented
2366
2371
  - `GeneratedBuildingData` (interface): interface GeneratedBuildingData — ⚠ undocumented
2367
2372
  - `GeneratedBuildingProps` (interface): interface GeneratedBuildingProps — ⚠ undocumented
2368
2373
  - `InstancedBuildingPlacement` (interface): interface InstancedBuildingPlacement — ⚠ undocumented
2369
- - `InstancedBuildings` (function): function InstancedBuildings({ buildings, palette, visibleKinds }: InstancedBuildingsProps): React.JSX.Element | null — ⚠ undocumented
2374
+ - `InstancedBuildings` (function): function InstancedBuildings({ buildings, palette, kit, resolveModelUrl, visibleKinds, }: InstancedBuildingsProps): React.JSX.Element | null — ⚠ undocumented
2370
2375
  - `InstancedBuildingsProps` (interface): interface InstancedBuildingsProps — ⚠ undocumented
2371
2376
 
2372
2377
  ## @jgengine/shell/structures/PlacementGhost
@@ -2384,6 +2389,13 @@
2384
2389
  - `TransformGizmoSnap` (type): type TransformGizmoSnap = "grid" | "free" | "ground" — Snap policy: grid quantize, free drag, or ground-height sample on release.
2385
2390
  - `TransformGizmoSpace` (type): type TransformGizmoSpace = "world" | "local" — Gizmo handle orientation: world axes, or the target's local (yaw-rotated) axes.
2386
2391
 
2392
+ ## @jgengine/shell/structures/buildingKitRegistry
2393
+
2394
+ - `clearBuildingKits` (function): function clearBuildingKits(): void — Drops every registration. For tests and hot-reload teardown.
2395
+ - `getBuildingKit` (function): function getBuildingKit(id: string | undefined): BuildingKit | undefined — The kit registered under `id`, or `undefined` for an unset or unknown name (renders blocks).
2396
+ - `listBuildingKits` (function): function listBuildingKits(): string[] — Every registered kit id — the editor's kit picker and diagnostics read this.
2397
+ - `registerBuildingKit` (function): function registerBuildingKit(kit: BuildingKit): void — Registers a kit under its own `id`, replacing any kit already registered under that name.
2398
+
2387
2399
  ## @jgengine/shell/terrain
2388
2400
 
2389
2401
  - `CarvedTerrain` (function): function CarvedTerrain({ field, size, segments, center, colors, heightRange, paletteAt, roughness = 0.95, metalness = 0, surfaceMaterial, receiveShadow = true, epoch = 0, ...meshProps }: CarvedTerrainProps): React.JSX.Element — Renders a `TerrainField` as a deformed ground mesh — the crater/mound view for destructible terrain. Because the geometry samples `field.sampleHeight`, a `CarvableField.carve(...)` shows as a real bowl once `epoch` changes. Pair with `InstancedBodies` to see debris resting in the crater it blasted.
@@ -2686,6 +2698,8 @@
2686
2698
 
2687
2699
  ## @jgengine/shell/weather
2688
2700
 
2701
+ - `DustField` (function): function DustField({ count = DEFAULT_DUST_COUNT, density = DEFAULT_DUST_DENSITY, budget, volume = DEFAULT_VOLUME, wind, origin = DEFAULT_ORIGIN, followCamera = true, speed = 2.4, size = 0.16, sway = 0.5, opacity = 0.3, color = DEFAULT_DUST_COLOR, groundBias = 0.65, timeScale, seed = 51407, renderOrd… — Renders wind-borne particulate as camera-facing motes. Reach for it whenever the air itself should read as weather — a desert gale, ash off a volcano, pollen in a meadow, grain dust in a silo. Compose it through {@link WeatherLayer}'s `"dust"` mode (or `dustAlways` alongside rain and snow) rather than mounting it directly, so it shares one wind and time source with the other layers.
2702
+ - `DustFieldProps` (interface): interface DustFieldProps — Wind-borne particulate: dust, sand, ash, pollen, spores. Unlike rain and snow this is not falling — motion is dominated by horizontal wind with a slow vertical bob, and density thins with height so the air near the ground carries the most grit. Any arid, volcanic, or blown-out world needs it.
2689
2703
  - `FireSpreadLayer` (function): function FireSpreadLayer({ grid, cellSize, origin = [0, 0], heightAt, flameHeight = 1.6, burningColor = "#ff6a1a", emberColor = "#4a1206", }: FireSpreadLayerProps): React.JSX.Element — ⚠ undocumented
2690
2704
  - `FireSpreadLayerProps` (interface): interface FireSpreadLayerProps — ⚠ undocumented
2691
2705
  - `LightningStrike` (function): function LightningStrike({ origin, target, strikeKey = 0, seed = 451, visible = true, duration = 0.18, color = DEFAULT_COLOR, glow = 2.4, branches = 5, jaggedness = 0.08, impactLight = 26, renderOrder = 20, }: LightningStrikeProps): React.JSX.Element — ⚠ undocumented
@@ -2694,13 +2708,18 @@
2694
2708
  - `RainFieldProps` (interface): interface RainFieldProps — ⚠ undocumented
2695
2709
  - `SnowField` (function): function SnowField({ count = DEFAULT_SNOW_COUNT, density = DEFAULT_SNOW_DENSITY, budget, volume = DEFAULT_VOLUME, wind, origin = DEFAULT_ORIGIN, followCamera = true, speed = 3.2, size = 0.11, sway = 0.62, opacity = 0.86, color = DEFAULT_SNOW_COLOR, timeScale, seed = 72931, renderOrder = 11, frustumC… — ⚠ undocumented
2696
2710
  - `SnowFieldProps` (interface): interface SnowFieldProps — ⚠ undocumented
2697
- - `WeatherLayer` (function): function WeatherLayer({ mode = "clear", intensity = 1, wind, lightning, timeScale, rain, snow, enabled = true, children, }: WeatherLayerProps): React.JSX.Element | null — ⚠ undocumented
2698
- - `WeatherLayerMode` (type): type WeatherLayerMode = "clear" | "rain" | "snow" | "mixed" — ⚠ undocumented
2711
+ - `WeatherLayer` (function): function WeatherLayer({ mode = "clear", intensity = 1, wind, lightning, timeScale, rain, snow, dust, dustAlways = false, enabled = true, children, }: WeatherLayerProps): React.JSX.Element | null — ⚠ undocumented
2712
+ - `WeatherLayerMode` (type): type WeatherLayerMode = "clear" | "rain" | "snow" | "mixed" | "dust" — `"mixed"` runs rain and snow together; `"dust"` is airborne particulate and composes with either via `dust`.
2699
2713
  - `WeatherLayerProps` (interface): interface WeatherLayerProps — ⚠ undocumented
2700
2714
  - `WeatherUniformOptions` (interface): interface WeatherUniformOptions — ⚠ undocumented
2701
2715
  - `WeatherUniformSet` (interface): interface WeatherUniformSet — ⚠ undocumented
2702
2716
  - `WeatherVector` (type): type WeatherVector = readonly [number, number, number] — ⚠ undocumented
2703
2717
 
2718
+ ## @jgengine/shell/weather/DustField
2719
+
2720
+ - `DustField` (function): function DustField({ count = DEFAULT_DUST_COUNT, density = DEFAULT_DUST_DENSITY, budget, volume = DEFAULT_VOLUME, wind, origin = DEFAULT_ORIGIN, followCamera = true, speed = 2.4, size = 0.16, sway = 0.5, opacity = 0.3, color = DEFAULT_DUST_COLOR, groundBias = 0.65, timeScale, seed = 51407, renderOrd… — Renders wind-borne particulate as camera-facing motes. Reach for it whenever the air itself should read as weather — a desert gale, ash off a volcano, pollen in a meadow, grain dust in a silo. Compose it through {@link WeatherLayer}'s `"dust"` mode (or `dustAlways` alongside rain and snow) rather than mounting it directly, so it shares one wind and time source with the other layers.
2721
+ - `DustFieldProps` (interface): interface DustFieldProps — Wind-borne particulate: dust, sand, ash, pollen, spores. Unlike rain and snow this is not falling — motion is dominated by horizontal wind with a slow vertical bob, and density thins with height so the air near the ground carries the most grit. Any arid, volcanic, or blown-out world needs it.
2722
+
2704
2723
  ## @jgengine/shell/weather/FireSpreadLayer
2705
2724
 
2706
2725
  - `FireSpreadLayer` (function): function FireSpreadLayer({ grid, cellSize, origin = [0, 0], heightAt, flameHeight = 1.6, burningColor = "#ff6a1a", emberColor = "#4a1206", }: FireSpreadLayerProps): React.JSX.Element — ⚠ undocumented
@@ -2723,12 +2742,14 @@
2723
2742
 
2724
2743
  ## @jgengine/shell/weather/WeatherLayer
2725
2744
 
2726
- - `WeatherLayer` (function): function WeatherLayer({ mode = "clear", intensity = 1, wind, lightning, timeScale, rain, snow, enabled = true, children, }: WeatherLayerProps): React.JSX.Element | null — ⚠ undocumented
2727
- - `WeatherLayerMode` (type): type WeatherLayerMode = "clear" | "rain" | "snow" | "mixed" — ⚠ undocumented
2745
+ - `WeatherLayer` (function): function WeatherLayer({ mode = "clear", intensity = 1, wind, lightning, timeScale, rain, snow, dust, dustAlways = false, enabled = true, children, }: WeatherLayerProps): React.JSX.Element | null — ⚠ undocumented
2746
+ - `WeatherLayerMode` (type): type WeatherLayerMode = "clear" | "rain" | "snow" | "mixed" | "dust" — `"mixed"` runs rain and snow together; `"dust"` is airborne particulate and composes with either via `dust`.
2728
2747
  - `WeatherLayerProps` (interface): interface WeatherLayerProps — ⚠ undocumented
2729
2748
 
2730
2749
  ## @jgengine/shell/weather/weatherMath
2731
2750
 
2751
+ - `DEFAULT_DUST_COUNT` (const): const DEFAULT_DUST_COUNT: 1800 — Mote budget a {@link DustField} allocates before `density` scales it down.
2752
+ - `DEFAULT_DUST_DENSITY` (const): const DEFAULT_DUST_DENSITY: 0.4 — Share of {@link DEFAULT_DUST_COUNT} drawn at intensity 1 — dust reads thinner than rain or snow.
2732
2753
  - `DEFAULT_RAIN_COUNT` (const): const DEFAULT_RAIN_COUNT: 2000 — ⚠ undocumented
2733
2754
  - `DEFAULT_RAIN_DENSITY` (const): const DEFAULT_RAIN_DENSITY: 0.45 — ⚠ undocumented
2734
2755
  - `DEFAULT_SNOW_COUNT` (const): const DEFAULT_SNOW_COUNT: 1500 — ⚠ undocumented
@@ -2781,9 +2802,9 @@
2781
2802
  - `ProjectileTracers` (function): function ProjectileTracers({ lifeMs = 130 }: { lifeMs?: number }): React.JSX.Element — ⚠ undocumented
2782
2803
  - `Reticle` (function): function Reticle({ className }: { className?: string }): React.JSX.Element — ⚠ undocumented
2783
2804
  - `WorldBarSample` (interface): interface WorldBarSample — ⚠ undocumented
2784
- - `WorldEntityBars` (function): function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, maxDistance = 60, }: { statId: string; height?: number; roles?: readonly CatalogEntityRole[]; resolveRole?: (entity: SceneEntity) => CatalogEntityRole | undefined; /** Hide bars for entities farther than this from the player (world… — ⚠ undocumented
2805
+ - `WorldEntityBars` (function): function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, maxDistance = 60, occlude = false, }: { statId: string; height?: number; roles?: readonly CatalogEntityRole[]; resolveRole?: (entity: SceneEntity) => CatalogEntityRole | undefined; /** Hide bars for entities farther than this from … — ⚠ undocumented
2785
2806
  - `WorldFloatText` (function): function WorldFloatText({ height = 1.9, lifeMs = 950 }: { height?: number; lifeMs?: number }): React.JSX.Element — ⚠ undocumented
2786
- - `WorldNameplates` (function): function WorldNameplates({ statId = "health", height = 2.3, roles, resolveRole, maxDistance = 40, tickMs = 120, showHealth = true, className, nameplateClassName, nameClassName, barClassName, fillClassName, renderNameplate, }: WorldNameplatesProps): React.JSX.Element — Billboarded name + 78×6px HP bar over every nearby non-local entity that passes `roles`/`maxDistance` — headless (className/data-* slots on every part, `renderNameplate` for a full swap), turned on declaratively via `defineGame({ nameplates })` rather than mounted by hand.
2807
+ - `WorldNameplates` (function): function WorldNameplates({ statId = "health", height = 2.3, roles, resolveRole, maxDistance = 40, occlude = false, tickMs = 120, showHealth = true, className, nameplateClassName, nameClassName, barClassName, fillClassName, renderNameplate, }: WorldNameplatesProps): React.JSX.Element — Billboarded name + 78×6px HP bar over every nearby non-local entity that passes `roles`/`maxDistance` — headless (className/data-* slots on every part, `renderNameplate` for a full swap), turned on declaratively via `defineGame({ nameplates })` rather than mounted by hand.
2787
2808
  - `WorldNameplatesProps` (interface): interface WorldNameplatesProps — Props for `WorldNameplates` — entity filter, refresh rate, and headless className/render hooks.
2788
2809
  - `WorldObjectHighlights` (function): function WorldObjectHighlights({ color = "#facc15", radius, y = 0.05 }: WorldObjectHighlightsProps): React.JSX.Element — Ground ring over every `ctx.scene.object.selection`-ed placed object — the object-layer counterpart to `WorldEntityBars`/`WorldNameplates`. Mount it once in the game's scene (headless: no defaults are imposed beyond a visible ring) instead of hand-rolling a selection highlight through `WorldOverlay` against external state.
2789
2810
  - `WorldObjectHighlightsProps` (interface): interface WorldObjectHighlightsProps — Props for {@link WorldObjectHighlights}.
@@ -342,6 +342,14 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
342
342
 
343
343
  - `createModalStack` (function) · `import { createModalStack } from "@jgengine/core/ui"`
344
344
 
345
+ ## model-fallbacks — models the loader replaced with a placeholder, for capture and smoke assertions
346
+
347
+ - `modelLoadFallbacks` (function) · `import { modelLoadFallbacks } from "@jgengine/shell/render/modelLoad"`
348
+
349
+ ## model-load-idle — how long the shared GLB loader has been idle, for capture settle waits
350
+
351
+ - `modelLoadIdleMs` (function) · `import { modelLoadIdleMs } from "@jgengine/shell/render/modelLoad"`
352
+
345
353
  ## nameplates — billboarded name + HP bar over nearby entities
346
354
 
347
355
  - `WorldNameplates` (function) · `import { WorldNameplates } from "@jgengine/shell/world/WorldHud"`
@@ -627,6 +635,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
627
635
 
628
636
  - `WaypointMarkers` (function) · `import { WaypointMarkers } from "@jgengine/react"`
629
637
 
638
+ ## weather-dust — airborne particulate that turns still air into visible wind
639
+
640
+ - `DustField` (function) · `import { DustField } from "@jgengine/shell/weather"`
641
+
630
642
  ## window — standalone draggable, closable window primitive over HudFrame — no manager required
631
643
 
632
644
  - `Window` (function) · `import { Window } from "@jgengine/react"`