From 4daed5d1e1fe77bf0aeca5021694e7b58b530d1d Mon Sep 17 00:00:00 2001 From: NoisemakerJon <139656120+Noisemaker111@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:27:31 -0400 Subject: [PATCH 1/3] Add_world_collider_synchronization --- packages/core/src/meta/changelog.ts | 1 + .../core/src/physics/worldColliders.test.ts | 33 ++++++ packages/core/src/physics/worldColliders.ts | 105 ++++++++++++++++++ scripts/export-manifest.json | 1 + 4 files changed, 140 insertions(+) create mode 100644 packages/core/src/physics/worldColliders.test.ts create mode 100644 packages/core/src/physics/worldColliders.ts diff --git a/packages/core/src/meta/changelog.ts b/packages/core/src/meta/changelog.ts index d43d78b6..fc654e2a 100644 --- a/packages/core/src/meta/changelog.ts +++ b/packages/core/src/meta/changelog.ts @@ -19,6 +19,7 @@ export const CHANGELOG: Record = { ], added: [ "`createPerception` (`@jgengine/core/sensor/perception`) tracks sight, sound, and damage stimuli per observer, with cone/range/occlusion checks, linearly decaying memory, retuning, and snapshot/restore.", + "`syncWorldColliders(backend, ctx)` mirrors static scene-object colliders and the sampled ground heightfield into a physics backend, updating on object spawn, despawn, and pose changes.", "Physics-backed player movement — `defineGame({ physics: { backend } })` installs the backend's `afterMovement` simulation stage and makes the shell/headless walk controller use a capsule for gravity, shapecast collision, slopes, and step-up, while retaining the existing input and impulse feel.", "`createPresenceFunctions` gains the policy hooks the pose lane was missing — `resolveSpawn(ctx, { serverId, userId, kind })` for where a session with no row starts (async and ctx-bearing, so a spawn can read the world, replacing a hardcoded origin), `poseRules` as a function of the actor `kind` so an agent need not obey a human's speed cap, `idleCutoffMs` / `maxReapPerRun` for the reaper, and optional `sessionId` on `sync` / `leave`: a second tab is a second row, syncing revokes the others, and the displaced session learns it on its next call through `displaced: true`.", "A command declares what it touches, so `runCommand` hydrates that and nothing else. `CommandDef` gains an optional `scope(input, actorUserId)` returning `{ players?, chunkKeys? }`, evaluated before hydration; the registered `runCommand` mutation also accepts an explicit `scope` argument, which wins over the declared one. A game that never sets either keeps the whole-world default. `ServerLoopHooks.joinScope(userId, isNew)` does the same for `onNewPlayer`.", diff --git a/packages/core/src/physics/worldColliders.test.ts b/packages/core/src/physics/worldColliders.test.ts new file mode 100644 index 00000000..cd0e6fa1 --- /dev/null +++ b/packages/core/src/physics/worldColliders.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { createObjectStore } from "../scene/objectStore"; +import { createPhysicsWorldBackend } from "./physicsWorldBackend"; +import { syncWorldColliders } from "./worldColliders"; + +describe("syncWorldColliders", () => { + test("named raycast hits subscribed static object and terrain heightfield", () => { + const object = createObjectStore(); + const colliders = new Map(); + const objectId = object.place("wall", 0, 1, -3); + colliders.set(objectId, { + body: { name: "wall", purpose: "physical", shape: { kind: "aabb", halfExtents: [2, 1, 0.2] } }, + }); + const ctx = { + scene: { object: { ...object, collidersOf: (id: string) => colliders.get(id) ?? null } }, + world: { ground: { bounds: { w: 20, d: 20 }, sampleHeight: () => 0 } }, + } as any; + const backend = createPhysicsWorldBackend({ + capacity: 8, + bounds: { min: [-20, -20, -20], max: [20, 20, 20] }, + warn: false, + }); + const sync = syncWorldColliders(backend, ctx); + + expect(backend.raycast({ origin: [0, 1, 0], direction: [0, 0, -1], maxDistance: 10 })?.body).toBe(2); + expect(backend.raycast({ origin: [0, 3, 0], direction: [0, -1, 0], maxDistance: 10 })?.body).toBe(1); + object.remove(objectId); + colliders.delete(objectId); + expect(backend.raycast({ origin: [0, 3, -3], direction: [0, 0, 1], maxDistance: 10 })).toBeNull(); + sync.dispose(); + backend.dispose(); + }); +}); diff --git a/packages/core/src/physics/worldColliders.ts b/packages/core/src/physics/worldColliders.ts new file mode 100644 index 00000000..5be6d192 --- /dev/null +++ b/packages/core/src/physics/worldColliders.ts @@ -0,0 +1,105 @@ +import type { GameContext } from "../runtime/gameContextTypes"; +import { colliderWorldCenter, resolveColliders, type ColliderShape } from "../scene/colliders"; +import type { EntityPosition } from "../scene/entityStore"; +import type { BodyHandle, BodyShape, PhysicsBackend, PhysicsVec3 } from "./physicsBackend"; + +const TERRAIN_SEGMENTS = 32; + +function bodyShape(shape: ColliderShape): BodyShape { + if (shape.kind === "sphere") return { kind: "sphere", radius: shape.radius }; + if (shape.kind === "aabb") return { kind: "box", halfExtents: shape.halfExtents }; + return { kind: "trimesh", vertices: shape.mesh.positions, indices: shape.mesh.indices }; +} + +function bodyPosition(shape: ColliderShape, position: EntityPosition, rotationY: number): PhysicsVec3 { + return colliderWorldCenter( + { name: "body", purpose: "physical", shape, damageEligible: false, blocks: true }, + position, + rotationY, + ); +} + +export interface WorldColliderSync { + /** Rebuild static terrain and object bodies from the current context. */ + sync(): void; + /** Remove all bodies owned by this synchronizer and stop reacting to scene changes. */ + dispose(): void; +} + +/** Mirrors static scene-object physical colliders and the context's ground field into a physics backend. */ +export function syncWorldColliders(backend: PhysicsBackend, ctx: GameContext): WorldColliderSync { + const objectBodies = new Map(); + let terrainBody: BodyHandle | null = null; + let disposed = false; + + function rebuildTerrain(): void { + if (terrainBody !== null) backend.removeBody(terrainBody); + terrainBody = null; + const bounds = ctx.world.ground.bounds; + if (bounds === undefined) return; + const columns = TERRAIN_SEGMENTS + 1; + const rows = TERRAIN_SEGMENTS + 1; + const heights = new Float32Array(columns * rows); + for (let row = 0; row < rows; row += 1) { + const z = -bounds.d / 2 + (row / TERRAIN_SEGMENTS) * bounds.d; + for (let column = 0; column < columns; column += 1) { + const x = -bounds.w / 2 + (column / TERRAIN_SEGMENTS) * bounds.w; + heights[row * columns + column] = ctx.world.ground.sampleHeight(x, z); + } + } + terrainBody = backend.addBody({ + shape: { kind: "heightfield", rows, columns, heights, scale: [bounds.w / TERRAIN_SEGMENTS, 1, bounds.d / TERRAIN_SEGMENTS] }, + position: [0, 0, 0], + kind: "static", + userData: { kind: "terrain" }, + }); + } + + function syncObject(objectId: string): void { + const old = objectBodies.get(objectId); + if (old !== undefined) { + backend.removeBody(old); + objectBodies.delete(objectId); + } + const object = ctx.scene.object.get(objectId); + if (object === null) return; + const body = resolveColliders(ctx.scene.object.collidersOf(objectId)).find((collider) => collider.purpose === "physical"); + if (body === undefined) return; + const handle = backend.addBody({ + shape: bodyShape(body.shape), + position: bodyPosition(body.shape, object.position, object.rotationY), + rotation: [0, Math.sin(object.rotationY / 2), 0, Math.cos(object.rotationY / 2)], + kind: "static", + userData: { kind: "object", instanceId: object.instanceId, catalogId: object.catalogId }, + }); + objectBodies.set(objectId, handle); + } + + function sync(): void { + if (disposed) return; + rebuildTerrain(); + const ids = new Set(ctx.scene.object.ids()); + for (const [objectId, handle] of objectBodies) { + if (!ids.has(objectId)) { + backend.removeBody(handle); + objectBodies.delete(objectId); + } + } + for (const objectId of ids) syncObject(objectId); + } + + const unsubscribe = ctx.scene.object.subscribe(sync); + sync(); + return { + sync, + dispose() { + if (disposed) return; + disposed = true; + unsubscribe(); + if (terrainBody !== null) backend.removeBody(terrainBody); + terrainBody = null; + for (const handle of objectBodies.values()) backend.removeBody(handle); + objectBodies.clear(); + }, + }; +} diff --git a/scripts/export-manifest.json b/scripts/export-manifest.json index dcd8166f..d42161d3 100644 --- a/scripts/export-manifest.json +++ b/scripts/export-manifest.json @@ -275,6 +275,7 @@ "./physics/traversal", "./physics/vehicleBody", "./physics/vehicleObstacles", + "./physics/worldColliders", "./procedural", "./progression/statGraph", "./puzzle/cellGrid", From 4afcdfc8f9c2e070811823e7a5364dc8d43003ec Mon Sep 17 00:00:00 2001 From: NoisemakerJon <139656120+Noisemaker111@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:42:28 -0400 Subject: [PATCH 2/3] docs-note-world-collider-sync --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b149bc8..9579f8ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ between (`--json` for structured output). ### Added - Core perception tracks deterministic sight, hearing, occlusion, and bounded observation memory for AI. +- `syncWorldColliders` now keeps terrain and static object colliders synchronized with a physics backend. - WS sessions now issue resume tickets and retain disconnected memberships for a 15-second grace window, allowing reconnects to rejoin without replacing player state. - Shell entity sprites can play atlas-backed sprite clips while preserving raw texture sprites. - Core sprite atlas adapters and deterministic 2D sprite clip playback primitives. From 47895fc7d79024ffe63284b22048c7f9ecf7e675 Mon Sep 17 00:00:00 2001 From: NoisemakerJon <139656120+Noisemaker111@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:55:28 -0400 Subject: [PATCH 3/3] docs-describe-world-collider-sync --- .claude/skills/jgengine-world/api.md | 5 +++++ .claude/skills/jgengine-world/capabilities.md | 4 ++++ packages/core/src/physics/worldColliders.ts | 5 ++++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.claude/skills/jgengine-world/api.md b/.claude/skills/jgengine-world/api.md index 17f4a4ef..16188d1b 100644 --- a/.claude/skills/jgengine-world/api.md +++ b/.claude/skills/jgengine-world/api.md @@ -998,6 +998,11 @@ - `VehicleObstacleClamp` (interface): interface VehicleObstacleClamp — A planar move clamp for a kinematic car: feed {@link clampMove} to a vehicle's `clampMove` hook so an attempted XZ displacement slides along world solids instead of driving through them, and read {@link takeImpact} once per tick for the crash it produced. - `createVehicleObstacleClamp` (function): function createVehicleObstacleClamp(options: { /** Solids near the car this tick (already filtered to the relevant, solid set). */ obstacles: () => readonly CollisionObstacle[]; /** Vehicle body radius (units). Default {@link DEFAULT_VEHICLE_RADIUS}. */ radius?: number; /** Current tick dt (seconds)… — Build a slide-along move clamp for a kinematic car (#1051). `obstacles` is sampled fresh each tick — the caller hands back the already-filtered set of solids near the car — and `dt` supplies the current tick length so a blocked move's lost displacement converts to a closing speed. `radius` inflates each obstacle footprint by the car's body radius (default {@link DEFAULT_VEHICLE_RADIUS}). +## @jgengine/core/physics/worldColliders + +- `WorldColliderSync` (interface): interface WorldColliderSync — Handle for synchronizing authored static world collision with a physics backend. +- `syncWorldColliders` (function): function syncWorldColliders(backend: PhysicsBackend, ctx: GameContext): WorldColliderSync — Mirrors static scene-object physical colliders and the context's ground field into a physics backend. + ## @jgengine/core/procedural - `DecayMeterSet` (interface): interface DecayMeterSet — Set of named survival meters (hunger/thirst/…) that drain and refill over game time. diff --git a/.claude/skills/jgengine-world/capabilities.md b/.claude/skills/jgengine-world/capabilities.md index 0d0e6814..69ddae80 100644 --- a/.claude/skills/jgengine-world/capabilities.md +++ b/.claude/skills/jgengine-world/capabilities.md @@ -664,6 +664,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `buildTrimmedIntersections` (function) · `import { buildTrimmedIntersections } from "@jgengine/core/world"` - `trimBandAtJunctions` (function) · `import { trimBandAtJunctions } from "@jgengine/core/world"` +## world-physics-colliders — Synchronize terrain and authored static colliders with a physics backend. + +- `syncWorldColliders` (function) · `import { syncWorldColliders } from "@jgengine/core/physics/worldColliders"` + ## world-place — declare the place a game happens in — flat/round/voxel/board ground, surface laws, per-place physics - `world` (function) · `import { world } from "@jgengine/core/world"` diff --git a/packages/core/src/physics/worldColliders.ts b/packages/core/src/physics/worldColliders.ts index 5be6d192..c66190f0 100644 --- a/packages/core/src/physics/worldColliders.ts +++ b/packages/core/src/physics/worldColliders.ts @@ -19,6 +19,7 @@ function bodyPosition(shape: ColliderShape, position: EntityPosition, rotationY: ); } +/** Handle for synchronizing authored static world collision with a physics backend. */ export interface WorldColliderSync { /** Rebuild static terrain and object bodies from the current context. */ sync(): void; @@ -26,7 +27,9 @@ export interface WorldColliderSync { dispose(): void; } -/** Mirrors static scene-object physical colliders and the context's ground field into a physics backend. */ +/** Mirrors static scene-object physical colliders and the context's ground field into a physics backend. + * @capability world-physics-colliders Synchronize terrain and authored static colliders with a physics backend. + */ export function syncWorldColliders(backend: PhysicsBackend, ctx: GameContext): WorldColliderSync { const objectBodies = new Map(); let terrainBody: BodyHandle | null = null;