@jgengine/core 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.
Files changed (57) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/game/playableGame.d.ts +36 -0
  3. package/dist/meta/changelog.d.ts +1 -1
  4. package/dist/meta/changelog.js +49 -1
  5. package/dist/movement/playerMovement.js +6 -172
  6. package/dist/movement/solidObstacles.d.ts +87 -0
  7. package/dist/movement/solidObstacles.js +208 -0
  8. package/dist/multiplayer.d.ts +1 -0
  9. package/dist/multiplayer.js +1 -0
  10. package/dist/runtime/context/objectSlots.d.ts +60 -0
  11. package/dist/runtime/context/objectSlots.js +103 -0
  12. package/dist/runtime/context/player.d.ts +3 -1
  13. package/dist/runtime/context/player.js +1 -0
  14. package/dist/runtime/context/scene.d.ts +9 -0
  15. package/dist/runtime/context/scene.js +25 -0
  16. package/dist/runtime/descriptors/baseline.d.ts +3 -1
  17. package/dist/runtime/descriptors/baseline.js +11 -0
  18. package/dist/runtime/gameContext.js +10 -1
  19. package/dist/runtime/gameContextTypes.d.ts +13 -0
  20. package/dist/runtime/gameRuntime.d.ts +6 -0
  21. package/dist/runtime/gameRuntime.js +1 -0
  22. package/dist/runtime/hostPersistence.d.ts +27 -2
  23. package/dist/runtime/hostPersistence.js +68 -12
  24. package/dist/runtime/hostPolicy.d.ts +54 -0
  25. package/dist/runtime/hostPolicy.js +53 -0
  26. package/dist/runtime/objectRows.d.ts +17 -0
  27. package/dist/runtime/objectRows.js +35 -0
  28. package/dist/runtime/snapshot.d.ts +15 -0
  29. package/dist/runtime/snapshot.js +12 -0
  30. package/dist/runtime/worldChunks.d.ts +39 -0
  31. package/dist/runtime/worldChunks.js +83 -0
  32. package/dist/runtime/worldProjection.d.ts +16 -2
  33. package/dist/runtime/worldProjection.js +20 -0
  34. package/dist/scene/behaviorRuntime.js +25 -3
  35. package/dist/scene/objectStore.d.ts +26 -0
  36. package/dist/scene/objectStore.js +51 -9
  37. package/dist/scene/spatial.d.ts +16 -0
  38. package/dist/scene/spatial.js +7 -4
  39. package/dist/world/buildingKit.d.ts +110 -0
  40. package/dist/world/buildingKit.js +93 -0
  41. package/dist/world/buildings.d.ts +25 -3
  42. package/dist/world/buildings.js +39 -5
  43. package/dist/world/cityKind.d.ts +6 -0
  44. package/dist/world/cityKind.js +3 -0
  45. package/dist/world/environmentSummary.js +13 -3
  46. package/dist/world/features.d.ts +7 -1
  47. package/dist/world/features.js +1 -0
  48. package/dist/world.d.ts +1 -0
  49. package/dist/world.js +1 -0
  50. package/package.json +1 -1
  51. package/skills/jgengine/api.md +38 -5
  52. package/skills/jgengine/capabilities.md +24 -0
  53. package/skills/jgengine-gameplay/api.md +2 -1
  54. package/skills/jgengine-world/SKILL.md +5 -0
  55. package/skills/jgengine-world/api.md +27 -2
  56. package/skills/jgengine-world/capabilities.md +24 -0
  57. package/skills/jgengine-world/recipes/building-kit.md +78 -0
@@ -247,6 +247,7 @@ export function building(config = {}) {
247
247
  ...(config.position === undefined ? {} : { position: config.position }),
248
248
  ...(config.palette === undefined ? {} : { palette: config.palette }),
249
249
  ...(config.along === undefined ? {} : { along: config.along }),
250
+ ...(config.kit === undefined ? {} : { kit: config.kit }),
250
251
  });
251
252
  }
252
253
  export function pad(config) {
package/dist/world.d.ts CHANGED
@@ -50,6 +50,7 @@ export { StructureGraph, type CollapseEvent, type StructureMaterial } from "./ph
50
50
  export { Glide, Grapple } from "./physics/traversal.js";
51
51
  export { DEFAULT_GRIP_CURVE, createVehicleBody, sampleGripCurve, type GripCurve } from "./physics/vehicleBody.js";
52
52
  export { createVehicleObstacleClamp, type VehicleImpact, type VehicleObstacleClamp, } from "./physics/vehicleObstacles.js";
53
+ export { type ObjectSlotRejection, type ObjectSlotResult, type ObjectSlotTransfer, type SceneObjectSlots, } from "./runtime/context/objectSlots.js";
53
54
  export { createAssetCatalog, type AssetCatalog, type ModelAssetRef, type ModelDims } from "./scene/assetCatalog.js";
54
55
  export { partsBounds, registerAssetGenerator, type GeneratedAsset, type GeneratedPart } from "./scene/assetGenerator.js";
55
56
  export { applyRotationPolicy, headingToRotationY, normalizeDegrees, parseAssetSpace, parseRotationPolicy, resolveAnchorOffset, resolveFacingRotationY, rotatedFootprint, rotationYToHeading, snapHeading, toEngineUnits, validateAssetSpace, type AssetAnchor, type AssetSpace, type AssetSpaceIssue, type PlacementRotationPolicy, } from "./scene/assetSpace.js";
package/dist/world.js CHANGED
@@ -50,6 +50,7 @@ export { StructureGraph } from "./physics/structure.js";
50
50
  export { Glide, Grapple } from "./physics/traversal.js";
51
51
  export { DEFAULT_GRIP_CURVE, createVehicleBody, sampleGripCurve } from "./physics/vehicleBody.js";
52
52
  export { createVehicleObstacleClamp, } from "./physics/vehicleObstacles.js";
53
+ export {} from "./runtime/context/objectSlots.js";
53
54
  export { createAssetCatalog } from "./scene/assetCatalog.js";
54
55
  export { partsBounds, registerAssetGenerator } from "./scene/assetGenerator.js";
55
56
  export { applyRotationPolicy, headingToRotationY, normalizeDegrees, parseAssetSpace, parseRotationPolicy, resolveAnchorOffset, resolveFacingRotationY, rotatedFootprint, rotationYToHeading, snapHeading, toEngineUnits, validateAssetSpace, } from "./scene/assetSpace.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/core",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "jgengine core — pure-TypeScript game framework SDK (npm @jgengine/core). Entity stores, commands, combat, inventory, multiplayer seams. Zero dependencies. Not an ECS. Agent skills ship in the tarball. https://jgengine.com",
5
5
  "keywords": [
6
6
  "jgengine",
@@ -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"`
@@ -695,7 +695,8 @@
695
695
  - `EntitySpriteConfig` (interface): interface EntitySpriteConfig — ⚠ undocumented
696
696
  - `FirstPersonCameraConfig` (interface): interface FirstPersonCameraConfig — ⚠ undocumented
697
697
  - `GameCameraConfig` (interface): interface GameCameraConfig — Camera tuning for the shell's rig stack: pick the rig via `rig`, then tune it through its matching config block. All fields optional — the default is the third-person orbit rig.
698
- - `GameCaptureConfig` (interface): interface GameCaptureConfig — How a screenshot host reaches live gameplay in this game — the data behind `shoot --mode play`.
698
+ - `GameCaptureConfig` (interface): interface GameCaptureConfig — How a game makes itself screenshottable: the commands that reach live play or a named screen, the framings worth re-capturing, and the progress metrics a bot playtest samples. Declaring these once is what lets a capture host reproduce a view instead of re-deriving it by hand every run.
699
+ - `GameCaptureView` (interface): interface GameCaptureView — How a screenshot host reaches live gameplay in this game — the data behind `shoot --mode play`.
699
700
  - `HemisphereLightingConfig` (interface): interface HemisphereLightingConfig — ⚠ undocumented
700
701
  - `InspectionCameraConfig` (interface): interface InspectionCameraConfig — Model-viewer / inspection rig (#207.7) — orbit + pan + anchored zoom around a fixed point, never reads player input.
701
702
  - `InspectionZoomAnchor` (type): type InspectionZoomAnchor = "target" | "cursor" | "center" — How scroll-zoom re-anchors the view for the inspection rig (#207.7): - `target` — dolly toward the orbit target (classic OrbitControls behavior). - `cursor` — dolly toward the point under the pointer. - `center` — dolly toward the viewport center; equivalent to `target` for an OrbitControls-driven rig, since the camera always faces `target` and that point already projects to the exact center of the viewport.
@@ -14,6 +14,7 @@ Search [capabilities.md](capabilities.md) by intent before designing a primitive
14
14
  For a *connected* walkthrough that wires several primitives into a running loop, read the goal-oriented recipes under [recipes/](recipes/) — organized by composition seam (what connects to what), never by genre. Start there before reading a game's source to learn how pieces fit; a hybrid game is just a different composition of the same primitives.
15
15
 
16
16
  - [recipes/commanding-entities.md](recipes/commanding-entities.md) — select → order → tick → resolve: direct command over a group of entities (the command loop behind squad control, base-defense, herding, autobattle, or a mutating game's real-time phase).
17
+ - [recipes/building-kit.md](recipes/building-kit.md) — bind the facade generator's part slots to real models: one massing, swappable art per style, blocks where the kit is silent.
17
18
 
18
19
  ## Canonical workflows
19
20
 
@@ -32,6 +33,10 @@ A world is the place you play in: substrate + laws, via `world()` from `@jgengin
32
33
 
33
34
  Choose input intent, controller/motor, collision/navigation, and camera as separate seams. Commands express game intent; world systems resolve motion. Interaction targets come from bounded spatial/sensor queries, not full-world scans.
34
35
 
36
+ ### Stateful placed objects
37
+
38
+ Per-instance state belongs on the placement, not in a game-owned map keyed by `instanceId`. Set `SceneObject.state` (opaque) through `ctx.scene.object.setState`/`patchState`; declare `slotInventory` on the catalog entry and mutate contents through `ctx.scene.object.slots`, which enforces the declared capacity and `accepts`. Both replicate and save with the object through the always-on `objects` snapshot module, and `toRuntimeObjectRow`/`fromRuntimeObjectRow` (`@jgengine/core/multiplayer`) convert to and from the host's persisted rows.
39
+
35
40
  ### AI and navigation
36
41
 
37
42
  Compose perception, selection, planning/behavior, movement, and lifecycle independently. Inject randomness and scheduling. Use spatial indexes, interest tiers, or bounded candidate sets for scale.
@@ -587,6 +587,12 @@
587
587
  - `PoseState` (interface): interface PoseState — ⚠ undocumented
588
588
  - `createPoseState` (function): function createPoseState(resolveAllowed: (instanceId: string) => PoseAllowedStates | null | undefined): PoseState — Stance/pose transitions — stand, crouch, prone — that change the hitbox and movement.
589
589
 
590
+ ## @jgengine/core/movement/solidObstacles
591
+
592
+ - `ObstacleReach` (interface): interface ObstacleReach — Largest blocking-collider extent in the scene, split so tower height cannot inflate the XZ query.
593
+ - `resolveWalkerStep` (function): function resolveWalkerStep(ctx: GameContext, position: EntityPosition, stepX: number, stepZ: number, options: { radius?: number; stepUpHeight?: number } = {}): { stepX: number; stepZ: number } — Slide a walker's horizontal step against the same solid geometry that stops the player, returning the X/Z it may actually take this tick. A step into a wall is cut on the blocked axis and preserved on the other, exactly as {@link stepPlayerMovement} resolves the player's.
594
+ - `solidObstaclesNear` (function): function solidObstaclesNear(ctx: GameContext, position: EntityPosition, reachX: number, reachZ: number, height = WALKER_HEIGHT): CollisionObstacle[] — Every blocking physical obstacle overlapping the box `position` ± `reachX`/`reachZ`, read from the scene's resolved colliders.
595
+
590
596
  ## @jgengine/core/movement/steering
591
597
 
592
598
  - `YawVectorXZ` (type): type YawVectorXZ = readonly [number, number] — Yaw-frame steering math. The engine's heading convention is `forward = (sin yaw, cos yaw)` on the XZ plane with +Y up, which makes a positive yaw increment rotate counterclockwise seen from above — a turn to the entity's LEFT on screen. Every hand-written `heading += steer * rate * dt` therefore turns the wrong way; integrate steering through `steerYaw` instead.
@@ -1617,7 +1623,7 @@
1617
1623
  - `BoundsSpec` (type): type BoundsSpec = | { readonly kind: "sphere"; readonly radius: number; readonly offset?: Vec3 } | { readonly kind: "aabb"; readonly half: Vec3; readonly offset?: Vec3 } | { readonly kind: "rect"; readonly halfWidth: number; readonly halfDepth: number; readonly halfHeight?: number; readonly offset?:… — How a renderable declares its extent. AABB, bounding sphere, and 2D rectangle cover the common cases; `point` is the degenerate zero-size default for objects that never override. `offset` shifts the volume from the object origin (e.g. a tall model whose pivot is at its feet).
1618
1624
  - `BoxFormationOptions` (interface): interface BoxFormationOptions — Options for {@link boxFormation}.
1619
1625
  - `BuildRole` (type): type BuildRole = "owner" | "editor" | "viewer" — ⚠ undocumented
1620
- - `BuildingEnvironmentDescriptor` (type): type BuildingEnvironmentDescriptor = { kind: "building" } & Required< Pick<BuildingEnvironmentConfig, "count" | "footprint" | "stories" | "storyHeight" | "spacing" | "style"> > & Pick<BuildingEnvironmentConfig, "seed" | "position" | "palette" | "along"> — ⚠ undocumented
1626
+ - `BuildingEnvironmentDescriptor` (type): type BuildingEnvironmentDescriptor = { kind: "building" } & Required< Pick<BuildingEnvironmentConfig, "count" | "footprint" | "stories" | "storyHeight" | "spacing" | "style"> > & Pick<BuildingEnvironmentConfig, "seed" | "position" | "palette" | "along" | "kit"> — ⚠ undocumented
1621
1627
  - `BuildingIndex` (interface): interface BuildingIndex — ⚠ undocumented
1622
1628
  - `BuildingPaletteOverrides` (type): type BuildingPaletteOverrides = Partial<BuildingPalette> — ⚠ undocumented
1623
1629
  - `BuildingStyle` (type): type BuildingStyle = | "generic" | "capital" | "village" | "desert" | "industrial" | "coastal" | "neon" | "ruin" | "frontier" | "aerial" — ⚠ undocumented
@@ -1837,6 +1843,9 @@
1837
1843
  - `NoiseFieldConfig` (interface): interface NoiseFieldConfig — Configuration for {@link noiseField}: seed, amplitude, and fractal noise shaping.
1838
1844
  - `NoiseVoice` (interface): interface NoiseVoice — A filtered white-noise burst — impacts, whooshes, breath, crackle. Realised from a shared 1s noise buffer at a randomised playback rate and start offset, decaying exponentially to silence at `duration * decay`.
1839
1845
  - `NoteEvent` (interface): interface NoteEvent — One scheduled note in a theme, positioned on the loop's quarter-note grid.
1846
+ - `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.
1847
+ - `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.
1848
+ - `ObjectSlotTransfer` (interface): interface ObjectSlotTransfer — Which player inventory a transfer moves between, and what it moves.
1840
1849
  - `ObjectVisual` (interface): interface ObjectVisual — ⚠ undocumented
1841
1850
  - `OceanEnvironmentDescriptor` (type): type OceanEnvironmentDescriptor = { kind: "ocean" } & Required< Pick<OceanEnvironmentConfig, "bounds" | "level" | "waveHeight" | "waveScale" | "waveSpeed" | "color"> > & Pick<OceanEnvironmentConfig, "position" | "levelAt"> — ⚠ undocumented
1842
1851
  - `Order` (interface): interface Order<TPayload = unknown> — One issued order. Fully serializable: `kind` is a registry key (not a closure), `payload` is the caller's plain data, and `state` is the kind's own plain progress scratch (e.g. a patrol waypoint index) written during `start`/`update`. Never holds functions, so the whole queue round-trips through `JSON`/`structuredClone`.
@@ -1951,6 +1960,7 @@
1951
1960
  - `SceneKindObject` (interface): interface SceneKindObject — The raw document object a resolver receives — shape shared by markers, volumes, and paths.
1952
1961
  - `SceneKindResolveContext` (interface): interface SceneKindResolveContext — Ground sampler + options a resolver may read (terrain height/normal snap).
1953
1962
  - `SceneObject` (interface): interface SceneObject — ⚠ undocumented
1963
+ - `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.
1954
1964
  - `SceneRaycastApi` (interface): interface SceneRaycastApi — ⚠ undocumented
1955
1965
  - `SceneRaycastHit` (interface): interface SceneRaycastHit — ⚠ undocumented
1956
1966
  - `ScreenEffect` (interface): interface ScreenEffect — A live effect in the current composite: its label, color, region, and the eased opacity `0..1` to draw *right now*. These objects are pooled and reused across {@link ScreenEffectsController.composite} calls — read them, don't retain.
@@ -2490,6 +2500,18 @@
2490
2500
  - `BuildingIndex` (interface): interface BuildingIndex — ⚠ undocumented
2491
2501
  - `buildingIndex` (function): function buildingIndex(buildings: readonly GeneratedBuilding[]): BuildingIndex — ⚠ undocumented
2492
2502
 
2503
+ ## @jgengine/core/world/buildingKit
2504
+
2505
+ - `BuildingKit` (interface): interface BuildingKit — Binds a building's generated part slots to models. A kit is plain serializable data — scene documents can carry one — and is deliberately unopinionated about art direction: the engine ships the seam, games ship the bindings.
2506
+ - `BuildingKitBinding` (type): type BuildingKitBinding = | { readonly type: "model"; readonly part: BuildingKitPart; readonly fit: BuildingKitFit } | { readonly type: "box" } | { readonly type: "omit" } — What a renderer should do with one generated part.
2507
+ - `BuildingKitFit` (type): type BuildingKitFit = "stretch" | "contain" | "cover" | "native" — How a kit model's native bounds are mapped onto the slot box `BuildingPartPlacement.scale` describes.
2508
+ - `BuildingKitInput` (interface): interface BuildingKitInput — The authoring shape {@link defineBuildingKit} normalizes into a {@link BuildingKit}.
2509
+ - `BuildingKitPart` (interface): interface BuildingKitPart — One bindable variant for a building part slot. `model` is an opaque reference the host resolves (an asset id, a pack-relative path, or a URL) — core never loads anything.
2510
+ - `BuildingKitPartInput` (type): type BuildingKitPartInput = string | BuildingKitPart — A variant as authored: a bare model reference, or the full {@link BuildingKitPart} when it needs fit or transform tuning.
2511
+ - `buildingKitVariantCounts` (function): function buildingKitVariantCounts(kit: BuildingKit): Partial<BuildingVariantCounts> — The variant counts a kit actually supplies, shaped for `BuildingConfigInput.variants`. Feed this into `generateBuilding` so seeded picks spread across exactly the models the kit binds instead of indexing past them and wrapping unevenly.
2512
+ - `defineBuildingKit` (function): function defineBuildingKit(input: BuildingKitInput): BuildingKit — Validates and normalizes a kit description, accepting bare model strings as single-field variants. Throws on empty variant lists so a typo fails at authoring time rather than silently falling back to untextured boxes.
2513
+ - `resolveBuildingKitPart` (function): function resolveBuildingKitPart(kit: BuildingKit | undefined, kind: BuildingPartKind, slot?: BuildingKitSlot): BuildingKitBinding — Resolves one generated part against a kit. Pass the placement's `kind` and its `kit` slot; the slot's `variant` wraps around the bound list, so a kit may carry any number of variants independent of the generator's `variants` counts.
2514
+
2493
2515
  ## @jgengine/core/world/buildingLots
2494
2516
 
2495
2517
  - `BuildingLotOptions` (interface): interface BuildingLotOptions — Options for {@link deriveBuildingLots}.
@@ -2523,6 +2545,9 @@
2523
2545
  - `DEFAULT_BUILDING_STYLE` (const): const DEFAULT_BUILDING_STYLE: BuildingStyle — ⚠ undocumented
2524
2546
  - `GeneratedBuilding` (interface): interface GeneratedBuilding — ⚠ undocumented
2525
2547
  - `Vec3` (type): type Vec3 = readonly [number, number, number] — ⚠ undocumented
2548
+ - `createBuildingConfig` (function): function createBuildingConfig(input: BuildingConfigInput = {}): BuildingConfig — Fill a partial building description out to a complete {@link BuildingConfig}, clamping counts and probabilities. Use it to inspect or adjust the resolved shape before generating.
2549
+ - `generateBuilding` (function): function generateBuilding(input: BuildingConfigInput = {}): GeneratedBuilding — Generate one building as a flat list of placed facade parts: bays × floors per facade, a ground row, corners, and a roof, with every decorative part rolled from `probabilities` and every part tagged with a `kit` slot the renderer binds to a model (see `world/buildingKit`). Deterministic from `seed`.
2550
+ - `generateBuildingDistrict` (function): function generateBuildingDistrict(config: BuildingGridConfig): GeneratedBuilding[] — Generate a grid of buildings on lots sized by `lotSize` and separated by `streetWidth`, each with its floor count rolled from `floorRange`.
2526
2551
 
2527
2552
  ## @jgengine/core/world/carve
2528
2553
 
@@ -2732,7 +2757,7 @@
2732
2757
  - `BiomeSky` (interface): interface BiomeSky — Per-band sky/light override cross-faded along z by `createBiomeSkySampler`; unset fields fall through to the base sky.
2733
2758
  - `BiomesWorldConfig` (interface): interface BiomesWorldConfig extends WorldGridConfig — ⚠ undocumented
2734
2759
  - `BuildingEnvironmentConfig` (interface): interface BuildingEnvironmentConfig — ⚠ undocumented
2735
- - `BuildingEnvironmentDescriptor` (type): type BuildingEnvironmentDescriptor = { kind: "building" } & Required< Pick<BuildingEnvironmentConfig, "count" | "footprint" | "stories" | "storyHeight" | "spacing" | "style"> > & Pick<BuildingEnvironmentConfig, "seed" | "position" | "palette" | "along"> — ⚠ undocumented
2760
+ - `BuildingEnvironmentDescriptor` (type): type BuildingEnvironmentDescriptor = { kind: "building" } & Required< Pick<BuildingEnvironmentConfig, "count" | "footprint" | "stories" | "storyHeight" | "spacing" | "style"> > & Pick<BuildingEnvironmentConfig, "seed" | "position" | "palette" | "along" | "kit"> — ⚠ undocumented
2736
2761
  - `BuildingFrontageConfig` (interface): interface BuildingFrontageConfig — Street-aware placement for `building()`: instead of a grid scattered around `position`, lots are stepped along each road's frontage, offset by a consistent setback (a curb + sidewalk strip), and turned so every building FRONT faces its road. `footprint` (`w` = frontage width, `d` = depth) and `spacing` (along-road gap) are shared with the grid mode. Deterministic and bounded by `maxLots`.
2737
2762
  - `BuildingFrontageRoad` (interface): interface BuildingFrontageRoad — One road a `building({ along })` frontage lines with buildings: centerline + full width.
2738
2763
  - `EnvironmentArea` (interface): interface EnvironmentArea extends WorldBounds — ⚠ undocumented
@@ -122,6 +122,18 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
122
122
 
123
123
  - `createBuildSocketCatalog` (function) · `import { createBuildSocketCatalog } from "@jgengine/core/world/buildSockets"`
124
124
 
125
+ ## building-generator — generate seeded buildings as placed facade parts — bays, floors, roof, decor
126
+
127
+ - `createBuildingConfig` (function) · `import { createBuildingConfig } from "@jgengine/core/world/buildings"`
128
+ - `generateBuilding` (function) · `import { generateBuilding } from "@jgengine/core/world/buildings"`
129
+ - `generateBuildingDistrict` (function) · `import { generateBuildingDistrict } from "@jgengine/core/world/buildings"`
130
+
131
+ ## building-kit — bind generated facade part slots to real models instead of untextured blocks
132
+
133
+ - `buildingKitVariantCounts` (function) · `import { buildingKitVariantCounts } from "@jgengine/core/world/buildingKit"`
134
+ - `defineBuildingKit` (function) · `import { defineBuildingKit } from "@jgengine/core/world/buildingKit"`
135
+ - `resolveBuildingKitPart` (function) · `import { resolveBuildingKitPart } from "@jgengine/core/world/buildingKit"`
136
+
125
137
  ## building-lots — derive street-facing building lots from road frontage
126
138
 
127
139
  - `deriveBuildingLots` (function) · `import { deriveBuildingLots } from "@jgengine/core/world/buildingLots"`
@@ -354,6 +366,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
354
366
 
355
367
  - `defineMoveOrder` (function) · `import { defineMoveOrder } from "@jgengine/core/world"`
356
368
 
369
+ ## object-slot-inventory — placed objects hold validated per-instance container contents that replicate and save with the placement
370
+
371
+ - `SceneObjectSlots` (interface) · `import { SceneObjectSlots } from "@jgengine/core/world"`
372
+
357
373
  ## order-queue — serializable per-entity command queue with lifecycle, preemption, and pluggable order kinds
358
374
 
359
375
  - `createOrderQueue` (function) · `import { createOrderQueue } from "@jgengine/core/world"`
@@ -480,6 +496,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
480
496
 
481
497
  - `SOIL_KIND` (const) · `import { SOIL_KIND } from "@jgengine/core/world"`
482
498
 
499
+ ## solid-obstacles — the one blocking-geometry query the player resolver and NPC movers share
500
+
501
+ - `solidObstaclesNear` (function) · `import { solidObstaclesNear } from "@jgengine/core/movement/solidObstacles"`
502
+
483
503
  ## spatial-region-box — sample uniformly within an axis-aligned 3D box
484
504
 
485
505
  - `boxRegion` (function) · `import { boxRegion } from "@jgengine/core/world"`
@@ -577,6 +597,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
577
597
 
578
598
  - `VolumetricCloudsConfig` (interface) · `import { VolumetricCloudsConfig } from "@jgengine/core/world"`
579
599
 
600
+ ## walker-solid-step — slide an NPC's step against the geometry that blocks the player
601
+
602
+ - `resolveWalkerStep` (function) · `import { resolveWalkerStep } from "@jgengine/core/movement/solidObstacles"`
603
+
580
604
  ## wave-runner — observable serializable wave/spawn runner over the seeded spawn director — WAVE N, wave progress, budget/alert readout, onSpawn sink, snapshot/restore
581
605
 
582
606
  - `createWaveRunner` (function) · `import { createWaveRunner } from "@jgengine/core/world"`
@@ -0,0 +1,78 @@
1
+ # Recipe — dressing generated buildings with a model kit
2
+
3
+ **What this wires:** the facade generator already emits every part slot a
4
+ building needs — walls, windows, storefronts, awnings, roof clutter. A
5
+ `BuildingKit` binds those slots to real GLBs, so the same massing renders as
6
+ untextured blocks, as a clean prefab town, or as a ruin, purely by swapping the
7
+ bound art.
8
+
9
+ ## Bind a pack to slots
10
+
11
+ ```ts
12
+ import { defineBuildingKit } from "@jgengine/core/world/buildingKit";
13
+
14
+ const kit = defineBuildingKit({
15
+ id: "settlement-clean",
16
+ parts: {
17
+ wall: ["/models/pack/Wall_Straight.glb", "/models/pack/Wall_Flat.glb"],
18
+ window: ["/models/pack/Wall_Window.glb"],
19
+ storefront: ["/models/pack/Door_Metal.glb"],
20
+ airConditioner: [{ model: "/models/pack/Vent_Big.glb", fit: "contain" }],
21
+ },
22
+ omit: ["clothesline"],
23
+ });
24
+ ```
25
+
26
+ A bare string is shorthand for `{ model }`. `fit` decides how the model meets
27
+ its slot box: `stretch` (default) fills the bay — right for tiling panels;
28
+ `contain` keeps a prop's proportions. `defineBuildingKit` throws on an empty
29
+ variant list, so a typo fails at authoring time instead of rendering nothing.
30
+
31
+ Resolve `model` through your game's asset catalog rather than typing URLs, and a
32
+ missing id fails at boot:
33
+
34
+ ```ts
35
+ const url = assets.resolve("pack/Wall_Straight")?.url;
36
+ ```
37
+
38
+ ## Hand it to the world
39
+
40
+ ```ts
41
+ import { building } from "@jgengine/core/world/features";
42
+
43
+ building({ position: [0, 0], count: 8, style: "desert", kit });
44
+ ```
45
+
46
+ Kinds the kit leaves unbound keep the engine's untextured block, so a partial
47
+ kit is a valid kit — bind walls and windows first, add roof clutter later.
48
+ Kinds listed in `omit` render nothing at all: use it for parts the setting
49
+ rejects, not for parts you have not got around to.
50
+
51
+ ## Two kits, one generator
52
+
53
+ Bind a second kit with broken panels and welded-shut doors, pass it to the
54
+ zones that should read as derelict, and the generator, seeds, and layout stay
55
+ identical. That split is the point of the seam — style is art binding, not a
56
+ second code path.
57
+
58
+ ## Spread the seeded picks
59
+
60
+ The generator's `variants` counts decide which slot index each bay asks for. If
61
+ a kit binds a different number of models than the config assumes, picks wrap
62
+ unevenly and one wall model dominates. When you drive the generator directly,
63
+ feed the kit's own counts back in:
64
+
65
+ ```ts
66
+ import { buildingKitVariantCounts } from "@jgengine/core/world/buildingKit";
67
+ import { generateBuilding } from "@jgengine/core/world/buildings";
68
+
69
+ generateBuilding({ seed: "town-1", variants: buildingKitVariantCounts(kit) });
70
+ ```
71
+
72
+ ## Common traps
73
+
74
+ - Do not bind `roof` to a wall-sized plate; the roof slot is one slab spanning
75
+ the whole footprint, not a tiling bay.
76
+ - `contain` for anything that must not distort (vents, crates, signs);
77
+ `stretch` for panels authored to fill a bay.
78
+ - `omit` and "unbound" are different outcomes — unbound still draws a block.