openvibers/openvibe.games Commits

Rapier replaces Havok behind the unchanged physics seam (ADR-0007 M1 steps 4-7), and Games serves its own legal pages (plan T11 decision 6).

OP OpenVibers committed 29 Sept 2026 · 9201a6f399 · 31 files, +1331 −921

Physics: packages/physics/src/rapier/ implements PhysicsWorld one-to-one on @dimforge/rapier3d-deterministic (capsule sweeps keep Havok's membership and collide masks; a query before the first step is served by a zero-dt step that builds the broad phase); the server, vehicles and terrain layer use it; the conformance and determinism suites run against Rapier and the mock. Havok is deleted: packages/physics/src/havok, the server's wasm loader and their build exclusions; the architecture audit now allows no Babylon import on the server at all.
Legal: /terms, /privacy and /dmca come from openvibe-shared/legal (games profile) in httpServer.ts before the SPA fallback (openvibe-shared v2.2.0); tests cover 200, HEAD, title, canonical and 404s for other legal-looking paths.
Slice test: the extraction hold's nudge toward the beacon is best effort and skipped once the player is home (under load it could time out after the recall and end an otherwise passing run).
typecheck, lint, 1074 tests (9 skipped), slice OK, bundle OK.

Changes

ddiff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
iindex cf9792f..6b85111 100644
---- a/ARCHITECTURE.md
++++ b/ARCHITECTURE.md
@@@ -6,7 +6,7 @@
every gameplay-relevant decision (movement, inventory, crafting, placement,
physgun, damage, economy) is validated or simulated server-side. No client
field is ever trusted.
--2. **Domain logic is engine-free.** `@openvibe/gameplay` has no Babylon, Havok,
++2. **Domain logic is engine-free.** `@openvibe/gameplay` has no Babylon, Rapier,
DOM, or network imports; it runs identically in vitest, on the server, and
inside client prediction. Engines live behind adapters.
3. **Data-driven content.** Items, recipes, and worlds are declarative
@@@ -24,7 +24,7 @@
```
shared
/ | \
-- protocol content physics ──(havok adapter: @babylonjs/core + havok wasm)
++ protocol content physics ──(rapier adapter: deterministic Rapier wasm)
\ | /
gameplay
|
@@@ -34,10 +34,13 @@
(authoritative) (Babylon render + prediction + HUD)
```
--Only `apps/*` and `packages/physics`'s adapter know Babylon exists. Only
--`apps/server` and `packages/persistence` know PostgreSQL (openvibe-sdk/db) exists. `packages/*`
--never import browser-only APIs (the physics adapter's NullEngine path is
--Node-safe; the client hands it a rendered scene instead).
++Only `apps/client` knows Babylon exists: ADR-0007 decision 2 removed the engine
++from the authoritative server, and an architecture audit
++(`apps/server/src/architectureAudit.audit.ts`) fails if `apps/server` imports
++`@babylonjs/*`. Only `apps/server` and `packages/persistence` know PostgreSQL
++(openvibe-sdk/db) exists. `packages/*` never import browser-only APIs: the
++physics adapter is Rapier, which runs identically in Node and the browser with no
++scene or engine, so there is no Node-only path to keep separate.
## Simulation model
@@@ -46,18 +49,19 @@ Node-safe; the client hands it a rendered scene instead).
- **Movement** is a kinematic Source/Quake controller (`gameplay/movement`):
explicit velocity + capsule sweeps + clip-plane sliding + step-up, with
friction / ground-accelerate / air-accelerate (air-strafing works). The
-- player is _not_ a dynamic rigid body; a kinematic Havok capsule mirrors the
++ player is _not_ a dynamic rigid body; a kinematic Rapier capsule mirrors the
player so props collide with them. The controller consumes a
-- `CollisionQueries` interface — the Havok adapter provides it on both sides,
-- the tests provide analytic worlds.
++ `CollisionQueries` interface — the Rapier adapter provides it on both sides
++ (one implementation, no scene), the tests provide analytic worlds.
- **Client prediction:** every input command (seq-stamped) is sent and applied
locally; snapshots carry the last processed seq; the client rewinds to the
authoritative state and replays unacked inputs. Remote entities render from
~130 ms interpolation buffers.
--- **Physics:** Havok via Babylon Physics V2 behind `PhysicsWorld`
-- (`@openvibe/physics`). Stepping is always manual (`executeStep`) from the fixed
-- loop — never coupled to render frames. Collision layers: Static / Prop /
-- Player, filtered at the shape level for rays and sweeps.
++- **Physics:** Rapier (`@dimforge/rapier3d-deterministic-compat`, one pinned
++ build on both sides) behind `PhysicsWorld` (`@openvibe/physics`). Stepping is
++ always manual (`world.step(dt)` from the fixed loop) — never coupled to render
++ frames. Collision layers: Static / Prop / Player, filtered at the shape level
++ for rays and sweeps.
## Networking
ddiff --git a/README.md b/README.md
iindex 94ca49c..d7dfdad 100644
---- a/README.md
++++ b/README.md
@@@ -8,7 +8,8 @@ physgun-driven building from crafted physical objects, gathering, crafting, and
extraction risk — running against an authoritative dedicated server.
**Stack:** TypeScript everywhere · Babylon.js (WebGPU with WebGL fallback) ·
--Havok physics (Babylon Physics V2, headless on the server via NullEngine) ·
++Rapier physics (`@dimforge/rapier3d-deterministic-compat`, one pinned build
++headless on the server and in client prediction) ·
WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm monorepo.
## Owns
@@@ -32,7 +33,7 @@ WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm
- OpenVibe.Network (SSO, JWKS, client-credentials tokens, identity resolve, the `games.progress.summary`
user module, mod grants), OpenVibe.Events (outbox relay, subscriptions), OpenVibe.Media (asset mirror)
- `openvibe-contracts` v0.77.0, `openvibe-sdk` v0.22.0 and `openvibe-shared` v1.30.1 (pinned by release
-- tarball in `apps/server/package.json`), Babylon.js, Havok, PostgreSQL (`pg`, or embedded PGlite in
++ tarball in `apps/server/package.json`), Babylon.js, Rapier, PostgreSQL (`pg`, or embedded PGlite in
development), Valkey (`iovalkey`, optional)
## Repository layout
@@@ -46,7 +47,7 @@ packages/
protocol/ Versioned wire messages + codec (zod-validated inbound)
content/ Data-driven item/recipe/world definitions + validation registry
gameplay/ Pure domain logic: inventory, crafting, movement sim, zones, entities
-- physics/ PhysicsWorld abstraction + Havok adapter (@openvibe/physics/havok)
++ physics/ PhysicsWorld abstraction + Rapier adapter (@openvibe/physics/rapier)
persistence/ DTOs, repository interfaces, PostgreSQL implementation, migrations
docs/adr/ Architecture decision records
```
ddiff --git a/ROADMAP.md b/ROADMAP.md
iindex 7017f85..e4d2838 100644
---- a/ROADMAP.md
++++ b/ROADMAP.md
@@@ -10,7 +10,7 @@ real playable vertical implementation.
Proved every major system cooperates end-to-end (verified by
`apps/server/scripts/sliceTest.ts` on every change): authoritative server,
Babylon client (WebGPU/WebGL), Source-style predicted movement, interest-
--managed replication, Havok props with sleep-aware snapshotting, gathering,
++managed replication, physics props with sleep-aware snapshotting, gathering,
inventory, data-driven crafting, physgun building, persistence across
restart, zone rules, logs//metrics//healthz.
ddiff --git a/STATUS.json b/STATUS.json
iindex 8836928..1b62050 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "stable",
"code": true,
"product": "Scraplandia",
-- "runtime": "node 22, TypeScript (tsx), pnpm workspace; Babylon.js + Havok; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in development); Valkey optional",
++ "runtime": "node 22, TypeScript (tsx), pnpm workspace; Babylon.js (client) + deterministic Rapier physics; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in development); Valkey optional",
"port": 8000,
"domain": "openvibe.games, play.openvibe.games",
"contracts": "openvibe-contracts v0.71.0, openvibe-sdk v0.22.0, openvibe-shared v1.30.1 (apps/server/package.json)",
@@@ -16,7 +16,7 @@
"deployed": true,
"deployment": "Public at https://openvibe.games and https://play.openvibe.games: deployed with `sudo ovhost deploy games` (OpenVibe.Host strategy pnpm-build since the 2026-09-27 cutover; deploy/scripts/deploy.sh is the wrapper), unit openvibe-games on 127.0.0.1:8000, env /etc/openvibe/games.env, principal games.",
"features": [
-- "authoritative multiplayer physics sandbox (Havok on the server)",
++ "authoritative multiplayer physics sandbox (deterministic Rapier on the server)",
"Source-style movement, physgun building, gathering, crafting, skills",
"persistent world, characters and props in PostgreSQL (write-behind flusher; the tick never awaits I/O)",
"map editor with assets mirrored to OpenVibe.Media",
ddiff --git a/apps/client/package.json b/apps/client/package.json
iindex a9abc3d..3e986f4 100644
---- a/apps/client/package.json
++++ b/apps/client/package.json
@@@ -11,7 +11,6 @@
"dependencies": {
"@babylonjs/addons": "^9.20.0",
"@babylonjs/core": "^9.20.0",
-- "@babylonjs/havok": "^1.3.10",
"@babylonjs/loaders": "^9.20.0",
"@babylonjs/materials": "^9.20.0",
"@babylonjs/procedural-textures": "^9.20.0",
ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex 29c7b22..327aba4 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -1,5 +1,3 @@
--import HavokPhysics from '@babylonjs/havok'
--import havokWasmUrl from '@babylonjs/havok/lib/esm/HavokPhysics.wasm?url'
import {
createContent,
setMapOverride,
@@@ -11,7 +9,7 @@ import {
type MapTextureEntry,
} from '@openvibe/content'
import { MapLightLayer, mapDiff, staticWork, terrainWork } from './render/mapRuntimeLayers.js'
--import { createHavokWorldForScene } from '@openvibe/physics/havok'
++import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
import { FixedTimestep } from '@openvibe/shared'
import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
import type { Appearance } from '@openvibe/protocol'
@@@ -90,8 +88,9 @@ async function start(): Promise<void> {
const mapLights = new MapLightLayer(scene)
mapLights.reconcile(mapLightsBoot ?? [])
-- // Havok loads while the player customizes their character.
-- const havokPromise = HavokPhysics({ locateFile: () => havokWasmUrl })
++ // Rapier loads while the player customizes their character (dynamic import,
++ // so physics is never part of the initial JavaScript).
++ const rapierPromise = loadRapier()
const environment = new Environment(scene, engine)
@@@ -156,8 +155,8 @@ async function start(): Promise<void> {
// away so the canvas gets the whole viewport (see public/frame.js).
window.dispatchEvent(new CustomEvent('ovg:playing'))
-- const havok = await havokPromise
-- const physics = createHavokWorldForScene(scene, havok)
++ const rapier = await rapierPromise
++ const physics = createRapierWorld(rapier)
const { buildStaticPhysics, rebuildTerrainPhysics } = await import('./game/staticPhysics.js')
buildStaticPhysics(physics, content)
ddiff --git a/apps/server/package.json b/apps/server/package.json
iindex 92bb4bc..e3df171 100644
---- a/apps/server/package.json
++++ b/apps/server/package.json
@@@ -7,22 +7,22 @@
"dev": "tsx watch --env-file-if-exists=.env src/main.ts",
"start": "tsx src/main.ts",
"build": "tsc --build",
-- "bundle": "esbuild src/main.ts --bundle --platform=node --format=esm --target=node22 --external:pg --external:iovalkey --external:@electric-sql/pglite --external:@babylonjs/havok --outfile=dist/server.mjs --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\""
++ "bundle": "esbuild src/main.ts --bundle --platform=node --format=esm --target=node22 --external:pg --external:iovalkey --external:@electric-sql/pglite --outfile=dist/server.mjs --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\""
},
"dependencies": {
-- "@babylonjs/havok": "^1.3.10",
++ "@dimforge/rapier3d-deterministic-compat": "0.21.0",
++ "@electric-sql/pglite": "^0.5.8",
"@openvibe/content": "workspace:*",
"@openvibe/gameplay": "workspace:*",
"@openvibe/persistence": "workspace:*",
"@openvibe/physics": "workspace:*",
"@openvibe/protocol": "workspace:*",
"@openvibe/shared": "workspace:*",
-- "@electric-sql/pglite": "^0.5.8",
"ajv": "^8.17.1",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.78.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0",
"tsx": "^4.19.3",
"ws": "^8.18.1"
ddiff --git a/apps/server/scripts/sliceTest.ts b/apps/server/scripts/sliceTest.ts
iindex 587ee58..a764069 100644
---- a/apps/server/scripts/sliceTest.ts
++++ b/apps/server/scripts/sliceTest.ts
@@@ -1747,9 +1747,12 @@ async function main(): Promise<void> {
while (Date.now() - start < 45_000) {
const me = a.me
if (me && Math.hypot(me.pos[0] - 0, me.pos[2] - 4) < 6 && secured()) break // both frames in
-- // Nudge back toward the beacon in case physics drift pushed us out.
++ // Nudge back toward the beacon in case physics drift pushed us out, but only while we are not already home
++ // (after the recall the site is behind us), and only as a best effort: the recall is the server's and lands
++ // wherever we stand, so a nudge that runs out of time must not end the run.
++ const home = me && Math.hypot(me.pos[0] - 0, me.pos[2] - 4) < 6
const dist = me ? Math.hypot(me.pos[0] - site[0], me.pos[2] - site[1]) : 99
-- if (me && dist > 3) await walkTo(a, site[0], site[1], 6000)
++ if (me && !home && dist > 3) await walkTo(a, site[0], site[1], 6000).catch(() => undefined)
await sleep(500)
}
}
ddiff --git a/apps/server/src/architectureAudit.audit.ts b/apps/server/src/architectureAudit.audit.ts
nnew file mode 100644
iindex 0000000..30c4d54
---- /dev/null
++++ b/apps/server/src/architectureAudit.audit.ts
@@@ -0,0 +1,90 @@
++/**
++ * ADR-0007 completion audit: the authoritative server is Babylon-free.
++ *
++ * Deleting Havok (ADR-0007 decision 2) removes `@babylonjs/core` and the
++ * NullEngine scene from the server; the server now runs Rapier only. That win
++ * is invisible to the type checker and to behavioural tests — a stray Babylon
++ * import "works" — so it needs a test of its own or the renderer quietly creeps
++ * back into the authoritative process.
++ *
++ * Only ACTIVE code counts: a mention in a comment is not an import.
++ */
++import { readdirSync, readFileSync, statSync } from 'node:fs'
++import { extname, join, relative } from 'node:path'
++import { describe, expect, it } from 'vitest'
++
++const REPO = new URL('../../../', import.meta.url).pathname
++const ROOT = 'apps/server'
++
++/** Server sources that legitimately mention Babylon: only this audit, which names the forbidden module. */
++const ALLOWED = ['apps/server/src/architectureAudit.audit.ts']
++
++interface SourceFile {
++ path: string
++ text: string
++}
++
++function collect(): SourceFile[] {
++ const out: SourceFile[] = []
++ const walk = (dir: string): void => {
++ for (const entry of readdirSync(dir)) {
++ const full = join(dir, entry)
++ if (statSync(full).isDirectory()) {
++ if (entry === 'node_modules' || entry === 'dist' || entry === 'dist-types') continue
++ walk(full)
++ continue
++ }
++ if (!['.ts', '.tsx'].includes(extname(entry))) continue
++ const rel = relative(REPO, full)
++ if (ALLOWED.some((a) => rel === a)) continue
++ out.push({ path: rel, text: readFileSync(full, 'utf8') })
++ }
++ }
++ walk(join(REPO, ROOT))
++ return out
++}
++
++const FILES = collect()
++
++/** Occurrences outside comments — a mention in prose is not an import. */
++function activeHits(needle: RegExp): { path: string; line: number; text: string }[] {
++ const hits: { path: string; line: number; text: string }[] = []
++ for (const f of FILES) {
++ let inBlockComment = false
++ f.text.split('\n').forEach((raw, i) => {
++ const line = raw.trim()
++ if (inBlockComment) {
++ if (line.includes('*/')) inBlockComment = false
++ return
++ }
++ if (line.startsWith('/*')) {
++ if (!line.includes('*/')) inBlockComment = true
++ return
++ }
++ if (line.startsWith('//') || line.startsWith('*')) return
++ if (needle.test(raw)) hits.push({ path: f.path, line: i + 1, text: line.slice(0, 110) })
++ })
++ }
++ return hits
++}
++
++const report = (hits: { path: string; line: number; text: string }[]): string =>
++ hits.map((h) => `${h.path}:${h.line} ${h.text}`).join('\n')
++
++describe('architecture audit: the server is Babylon-free (ADR-0007)', () => {
++ it('scans a meaningful number of server source files', () => {
++ expect(FILES.length).toBeGreaterThan(40)
++ })
++
++ it('imports no @babylonjs/* anywhere in the server', () => {
++ expect(report(activeHits(/@babylonjs\//))) .toBe('')
++ })
++
++ it('drives Rapier (not Havok) and has no Havok loader', () => {
++ // Positive half of the claim: the server names Rapier and nothing else.
++ const main = FILES.find((f) => f.path === 'apps/server/src/main.ts')
++ expect(main, 'server main.ts should exist').toBeDefined()
++ expect(main!.text).toContain('@openvibe/physics/rapier')
++ expect(main!.text).not.toMatch(/havok/i)
++ })
++})
ddiff --git a/apps/server/src/game/mapTerrainLayer.ts b/apps/server/src/game/mapTerrainLayer.ts
iindex 5e3fb25..c7365cd 100644
---- a/apps/server/src/game/mapTerrainLayer.ts
++++ b/apps/server/src/game/mapTerrainLayer.ts
@@@ -91,9 +91,9 @@ export class MapTerrainLayer {
private addBody(patch: TerrainPatchData): BodyId {
const grid = buildPatchGrid(patch.halfExtent, patch.sub, patch.heights)
const rot = patch.rot ? qfromEuler(quat(), patch.rot[0], patch.rot[1], patch.rot[2]) : quat()
-- // Scale the VERTICES rather than the body: Babylon/Havok apply
-- // scale→rotate→translate, matching the client mesh's `scaling`, so the
-- // two cannot disagree about where the ground is.
++ // Scale the VERTICES rather than the body: Rapier has no body scale, so
++ // baking it into the local vertices is the only way to match the client
++ // mesh's `scaling`, and the two cannot disagree about where the ground is.
const positions = scalePatchPositions(grid.positions, patch.scale)
return this.physics.addBody({
shape: { type: 'trimesh', positions, indices: grid.indices },
ddiff --git a/apps/server/src/game/platformIntegration.test.ts b/apps/server/src/game/platformIntegration.test.ts
iindex 541c27d..594c2d4 100644
---- a/apps/server/src/game/platformIntegration.test.ts
++++ b/apps/server/src/game/platformIntegration.test.ts
@@@ -1,6 +1,6 @@
/**
* The Wave 12 platform boundary exercised on the real authoritative server
-- * (headless Havok, real PostgreSQL schema through in-memory PGlite,
++ * (headless Rapier, real PostgreSQL schema through in-memory PGlite,
* protocol-level connections), across a restart:
*
* - a signed-in player is keyed by the canonical subject;
@@@ -19,7 +19,7 @@ import { join } from 'node:path'
import { createContent } from '@openvibe/content'
import { openPgStore, type PgPersistenceStore } from '@openvibe/persistence'
import { openTestDb } from '@openvibe/persistence/testing'
--import { createHeadlessHavokWorld } from '@openvibe/physics/havok'
++import { createRapierWorld, loadRapier, type RapierModule } from '@openvibe/physics/rapier'
import {
PROTOCOL_VERSION,
defaultAppearance,
@@@ -32,7 +32,6 @@ import { createEventsClient, createPgOutbox, type PgOutbox } from 'openvibe-sdk/
import { createMockPlatform } from 'openvibe-sdk/testing'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { loadConfig } from '../config.js'
--import { loadHavok } from '../havokLoader.js'
import { hashGuestKey } from '../net/guestIdentity.js'
import { CAP_ANNOUNCE, CAP_PLACE_PROP } from '../mods/contentPack.js'
import { ModRegistry, type ModActor } from '../mods/registry.js'
@@@ -55,12 +54,12 @@ const config = loadConfig({
MAP_PATH: join(dir, 'map.json'),
OV_NETWORK_AUTH_URL: 'http://network.test/api/auth/me',
})
--let havok: unknown
++let rapier: RapierModule
/** One in-memory PGlite database for the whole file; the restart tests share its state. */
let db: Db
beforeAll(async () => {
-- havok = await loadHavok()
++ rapier = await loadRapier()
db = await openTestDb()
// openvibe.network's /api/auth/me for the one signed-in test account.
vi.stubGlobal('fetch', async (_url: string, init?: RequestInit) => {
@@@ -85,7 +84,7 @@ interface Boot {
async function boot(platform: ReturnType<typeof createMockPlatform>): Promise<Boot> {
const content = createContent()
-- const physics = createHeadlessHavokWorld(havok)
++ const physics = createRapierWorld(rapier)
// The store rides on the shared database; close() is never called (the instance is shared).
const store = openPgStore(db, { placeId: config.placeId })
await store.ensurePlace()
ddiff --git a/apps/server/src/game/systems/vehicles.ts b/apps/server/src/game/systems/vehicles.ts
iindex ba458cf..4011f9c 100644
---- a/apps/server/src/game/systems/vehicles.ts
++++ b/apps/server/src/game/systems/vehicles.ts
@@@ -12,6 +12,15 @@ const _vehicleForce = vec3()
const _vehicleAng = vec3()
const _vehicleVel = vec3()
++/**
++ * Vertical clearance between a riding player's replicated perch and their
++ * collision hull. Rapier resolves an animated hull that overlaps a dynamic
++ * chassis into a depenetration impulse, so a hull left at the perch pushes the
++ * cart into the ground and pins it. The standing capsule half-height is 0.75 m
++ * (STANCE_HULL 1.8 minus the 0.3 shorter body hull), which clears the vehicle.
++ */
++const RIDER_HULL_LIFT = 0.75
++
export class VehicleSystem implements System {
readonly name = 'vehicles'
@@@ -174,8 +183,13 @@ export class VehicleSystem implements System {
session.fallVy = 0
const body = this.ctx.sessions.bodyOf(session.playerId)
if (body !== undefined) {
++ // Rapier resolves an animated body that overlaps a dynamic body into a
++ // depenetration impulse, so a rider's hull left inside the chassis pushes
++ // the cart into the ground and brakes it. Perch the rider's hull clear of
++ // the vehicle instead; the replicated/rendered perch above
++ // (session.move.pos) is unchanged, so nothing visible moves.
_bodyPosScratch.x = session.move.pos.x
-- _bodyPosScratch.y = session.move.pos.y + 0.15
++ _bodyPosScratch.y = session.move.pos.y + RIDER_HULL_LIFT
_bodyPosScratch.z = session.move.pos.z
this.ctx.world.physics.setTransform(body, _bodyPosScratch)
}
ddiff --git a/apps/server/src/havokLoader.ts b/apps/server/src/havokLoader.ts
ddeleted file mode 100644
iindex 1b68dc1..0000000
---- a/apps/server/src/havokLoader.ts
++++ /dev/null
@@@ -1,14 +0,0 @@
--import { readFile } from 'node:fs/promises'
--import { createRequire } from 'node:module'
--import HavokPhysics from '@babylonjs/havok'
--
--/**
-- * Loads the Havok wasm module in Node. Emscripten's default file resolution
-- * expects a browser, so the wasm binary is read from the package explicitly.
-- */
--export async function loadHavok(): Promise<unknown> {
-- const require = createRequire(import.meta.url)
-- const wasmPath = require.resolve('@babylonjs/havok/lib/esm/HavokPhysics.wasm')
-- const wasmBinary = (await readFile(wasmPath)).buffer as ArrayBuffer
-- return HavokPhysics({ wasmBinary })
--}
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex bb8eafa..6bbdd39 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -5,13 +5,12 @@ import { createEventsClient, createPgOutbox, type PgOutbox } from 'openvibe-sdk/
import { createValkey } from 'openvibe-sdk/valkey'
import { compileMapFileV2, createContent, parseMapFile, setMapOverride } from '@openvibe/content'
import { openPgStore } from '@openvibe/persistence'
--import { createHeadlessHavokWorld } from '@openvibe/physics/havok'
++import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
import { createConsoleLogger } from '@openvibe/shared'
import { loadConfig } from './config.js'
import { openDb } from './db.js'
import { GameServer } from './game/gameServer.js'
import { GameWorld } from './game/gameWorld.js'
--import { loadHavok } from './havokLoader.js'
import { attachEditorWs } from './net/editorWs.js'
import {
closeSockets,
@@@ -99,8 +98,7 @@ async function main(): Promise<void> {
world: content.world.id,
})
-- const havok = await loadHavok()
-- const physics = createHeadlessHavokWorld(havok)
++ const physics = createRapierWorld(await loadRapier())
// PostgreSQL (or embedded PGlite in development), migrated before anything writes.
const db = await openDb(config.db, log)
ddiff --git a/apps/server/src/net/httpServer.ts b/apps/server/src/net/httpServer.ts
iindex f7a4f91..ca5179c 100644
---- a/apps/server/src/net/httpServer.ts
++++ b/apps/server/src/net/httpServer.ts
@@@ -30,6 +30,7 @@ import type { GamesActorLimits } from './actorLimits.js'
import { resolveClientAddress } from './clientAddress.js'
import { sendNotFound, type NotFoundLink } from './notFound.js'
import { discoveryHandler, homeJsonLdTags } from './discovery.js'
++import { legalHandler } from './legalPages.js'
/** The portal page with its JSON-LD, built once per file version: the game server's event loop never reads the file
* per request (a new build changes the mtime and rebuilds it once). */
@@@ -181,6 +182,8 @@ export function createHttpServer(
{ href: '/editor', label: 'Map editor' },
{ href: portalHome, label: 'OpenVibe.Games home' },
]
++ // The site's own /terms, /privacy and /dmca (openvibe-shared/legal), built once for the process.
++ const serveLegal = legalHandler()
return createServer((req: IncomingMessage, res: ServerResponse) => {
const url = (req.url ?? '/').split('?')[0] ?? '/'
// Host-based routing: play.openvibe.games serves the game at its root
@@@ -215,6 +218,9 @@ export function createHttpServer(
if (platform?.handle?.(req, res)) return
// Crawl artifacts (plan T11): /robots.txt, /sitemap.xml and /llms.txt, built by openvibe-shared/seo.
if (discoveryHandler(req, res)) return
++ // The site's own legal pages (plan T11 decision 6): /terms, /privacy, /dmca. Before the SPA and the
++ // static fallback so they never resolve to a file or the landing page.
++ if (serveLegal(req, res)) return
if (url === '/map.json' && mapPath) {
// The canonical v2 document, with its revision as an ETag. This IS the
// wire format.
ddiff --git a/apps/server/src/net/legalPages.test.ts b/apps/server/src/net/legalPages.test.ts
nnew file mode 100644
iindex 0000000..5095735
---- /dev/null
++++ b/apps/server/src/net/legalPages.test.ts
@@@ -0,0 +1,115 @@
++import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
++import { request, type IncomingHttpHeaders, type OutgoingHttpHeaders, type Server } from 'node:http'
++import type { AddressInfo } from 'node:net'
++import { tmpdir } from 'node:os'
++import { join } from 'node:path'
++import { createConsoleLogger } from '@openvibe/shared'
++import { afterAll, beforeAll, describe, expect, it } from 'vitest'
++import { ServerMetrics } from '../observability/metrics.js'
++import { createHttpServer } from './httpServer.js'
++
++/**
++ * The site's own legal pages (plan T11 decision 6): /terms, /privacy and /dmca are rendered by
++ * openvibe-shared/legal with the Games profile, fetched from the real HTTP layer over a stand-in
++ * client build. An unknown /legal-ish path is still a real 404 (notFound.ts), never the landing page.
++ */
++
++const log = createConsoleLogger({ app: 'test' }, 'error')
++const APEX = 'openvibe.games'
++const PAGES: readonly (readonly [string, string])[] = [
++ ['/terms', 'Terms of Service'],
++ ['/privacy', 'Privacy Policy'],
++ ['/dmca', 'DMCA &amp; Copyright'],
++]
++
++interface Reply {
++ status: number
++ headers: IncomingHttpHeaders
++ body: string
++}
++
++let dir: string
++let server: Server
++let port = 0
++
++function listen(s: Server): Promise<number> {
++ return new Promise((resolve) =>
++ s.listen(0, '127.0.0.1', () => resolve((s.address() as AddressInfo).port)),
++ )
++}
++
++function send(
++ path: string,
++ opts: { method?: string; headers?: OutgoingHttpHeaders } = {},
++): Promise<Reply> {
++ return new Promise((resolve, reject) => {
++ const req = request(
++ {
++ host: '127.0.0.1',
++ port,
++ path,
++ method: opts.method ?? 'GET',
++ headers: { host: APEX, ...opts.headers },
++ },
++ (res) => {
++ const chunks: Buffer[] = []
++ res.on('data', (c: Buffer) => chunks.push(c))
++ res.on('end', () =>
++ resolve({
++ status: res.statusCode ?? 0,
++ headers: res.headers,
++ body: Buffer.concat(chunks).toString('utf8'),
++ }),
++ )
++ },
++ )
++ req.on('error', reject)
++ req.end()
++ })
++}
++
++beforeAll(async () => {
++ dir = mkdtempSync(join(tmpdir(), 'ovg-legal-'))
++ const dist = join(dir, 'dist')
++ mkdirSync(dist, { recursive: true })
++ writeFileSync(join(dist, 'index.html'), '<!doctype html><title>portal page</title>')
++ writeFileSync(join(dist, 'play.html'), '<!doctype html><title>game page</title>')
++ server = createHttpServer(dist, new ServerMetrics(), log)
++ port = await listen(server)
++})
++
++afterAll(() => {
++ server.close()
++ rmSync(dir, { recursive: true, force: true })
++})
++
++describe('legal pages (plan T11 decision 6)', () => {
++ it('serves each path as its own page: text/html, the title and a canonical on openvibe.games', async () => {
++ for (const [path, title] of PAGES) {
++ const r = await send(path)
++ expect(r.status).toBe(200)
++ expect(r.headers['content-type']).toBe('text/html; charset=utf-8')
++ expect(r.body).toContain(`<title>${title} — OpenVibe.Games</title>`)
++ expect(r.body).toContain(`<link rel="canonical" href="https://openvibe.games${path}">`)
++ expect(r.body).toContain(`<h1>${title}</h1>`)
++ }
++ })
++
++ it('answers HEAD like GET, headers only', async () => {
++ for (const [path] of PAGES) {
++ const r = await send(path, { method: 'HEAD' })
++ expect(r.status).toBe(200)
++ expect(r.headers['content-type']).toBe('text/html; charset=utf-8')
++ expect(r.body).toBe('')
++ }
++ })
++
++ it('still 404s a legal-ish path it does not own, through notFound.ts', async () => {
++ for (const path of ['/legal', '/terms/extra', '/tos', '/privacy.html']) {
++ const r = await send(path)
++ expect(r.status).toBe(404)
++ expect(r.headers['content-type']).toBe('text/html; charset=utf-8')
++ expect(r.body).toContain('Page not found')
++ }
++ })
++})
ddiff --git a/apps/server/src/net/legalPages.ts b/apps/server/src/net/legalPages.ts
nnew file mode 100644
iindex 0000000..777bb84
---- /dev/null
++++ b/apps/server/src/net/legalPages.ts
@@@ -0,0 +1,80 @@
++import type { IncomingMessage, ServerResponse } from 'node:http'
++import { createRequire } from 'node:module'
++
++/**
++ * GET /terms, /privacy and /dmca (plan T11 decision 6): the site answers its own legal pages with
++ * openvibe-shared/legal, as every other product does. openvibe.games used to be answered by the
++ * OpenVibe.Sites placeholder (nginx served /opt/openvibe.sites/dist/openvibe.games for them); Sites
++ * is going away, so the pages move here.
++ *
++ * The profile is 'games': legal.js switches clauses by profile, and 'games' is the one that fits a
++ * game with accounts, chat and user-made maps — it adds the Games section (in development, play
++ * fair, no cheats, game-chat rules), the user-content licence (user-made maps) and, in the privacy
++ * document, the Game data bullet. The account clauses are included for every non-'info' profile.
++ *
++ * legal.handler is Express-shaped (this server is plain node:http, like sharedAssets.ts), so a tiny
++ * req/res shim stands in for Express: req.path and res.set/res.send are all it touches.
++ */
++interface LegalSite {
++ id: string
++ service: string
++ host: string
++ name: string
++ profile: string
++}
++interface LegalReq {
++ path: string
++}
++interface LegalRes {
++ set(name: string, value: string): void
++ send(body: string): void
++}
++interface LegalModule {
++ PATHS: string[]
++ handler(site: LegalSite): (req: LegalReq, res: LegalRes, next: () => void) => void
++}
++
++const SITE: LegalSite = {
++ id: 'games',
++ service: 'games',
++ host: 'openvibe.games',
++ name: 'OpenVibe.Games',
++ profile: 'games',
++}
++
++export function legalHandler(req: NodeRequire = createRequire(import.meta.url)) {
++ const legal = req('openvibe-shared/legal') as LegalModule
++ const paths = new Set(legal.PATHS)
++ // One handler for the process: openvibe-shared caches each rendered page after the first request.
++ const handle = legal.handler(SITE)
++ return (rq: IncomingMessage, res: ServerResponse): boolean => {
++ const url = (rq.url ?? '/').split('?')[0] ?? '/'
++ if (!paths.has(url)) return false
++ if (rq.method !== 'GET' && rq.method !== 'HEAD') {
++ res.writeHead(405, { allow: 'GET, HEAD' })
++ res.end()
++ return true
++ }
++ const headers: Record<string, string> = {}
++ handle(
++ { path: url },
++ {
++ set: (name, value) => {
++ headers[name.toLowerCase()] = value
++ },
++ send: (body) => {
++ res.writeHead(200, headers)
++ // node:http writes no body for HEAD; the headers are the same.
++ res.end(rq.method === 'HEAD' ? undefined : body)
++ },
++ },
++ () => {
++ // legal.PATHS and the shared handler's documents are the same list; unreachable, but a
++ // path it does not know must not fall through as a 200.
++ res.writeHead(404)
++ res.end()
++ },
++ )
++ return true
++ }
++}
ddiff --git a/docs/adr/0003-headless-havok-via-babylon-nullengine.md b/docs/adr/0003-headless-havok-via-babylon-nullengine.md
iindex 60dd0fc..c97b773 100644
---- a/docs/adr/0003-headless-havok-via-babylon-nullengine.md
++++ b/docs/adr/0003-headless-havok-via-babylon-nullengine.md
@@@ -1,6 +1,12 @@
# ADR-0003: Server physics = Havok wasm via Babylon NullEngine adapter
--**Status:** accepted · **Date:** 2026-08-09
++**Status:** superseded by ADR-0007 · **Date:** 2026-08-09
++
++> Superseded 2026-09-29: ADR-0007 decision 2 replaces Havok and the Babylon
++> NullEngine host with Rapier on both sides, so `packages/physics/src/havok` and
++> the NullEngine scene are gone. The rule this ADR established — one physics
++> implementation behind `PhysicsWorld`, manual stepping, no engine types across
++> the boundary — is kept; only the engine changed.
## Decision
ddiff --git a/docs/adr/0007-rewrite-the-moddable-base.md b/docs/adr/0007-rewrite-the-moddable-base.md
iindex fe2227a..8ac2053 100644
---- a/docs/adr/0007-rewrite-the-moddable-base.md
++++ b/docs/adr/0007-rewrite-the-moddable-base.md
@@@ -60,6 +60,47 @@ no mobile path, and pins that lag the platform.
12. **Pin drift is a defect.** Contracts and SDK pins move to current; CI fails when the installed contracts package
rejects a manifest Games accepts, or the other way round.
++## M1 physics decisions (2026-09-29)
++
++Answers to the open questions in the M1 survey (`brief-games-m1-next.out.md`), decided after the state-hash
++harness (M1 step 2) had measured the mock against a real engine. Decisions 8–9 were taken because the
++cross-engine tolerance test forked on a discrete branch, not on floating-point noise.
++
++1. **Determinism means two things, tested separately.** Same runtime (Node against Node, the same inputs):
++ the movement state hash is bit-identical every tick; the test fails on any difference. Across runtimes
++ (server Node against a browser): positions agree within 1 mm and velocities within 1 cm/s per tick, and
++ the client reconciles (snaps to the server) past 5 cm. The movement kernel stays plain JavaScript (f64);
++ no shared f32 math kernel in M1. The tolerance is written here as a tolerance, never called bit-identity.
++2. **Rapier builds.** One pinned version of the deterministic build on both sides: the `-compat` package on
++ the server (Node, inlined WASM, no loader), the non-compat package in the browser with the `.wasm` as a
++ separate, cached, compressed asset. Physics loads with a dynamic import while the WebSocket ticket is
++ fetched, so it is not initial JavaScript and the M6 budget (2.5 MB initial JS) is untouched. A test
++ asserts both packages resolve to the same version.
++3. **Look input stays absolute.** Each input carries yaw and pitch as absolute angles quantized to int16
++ (1/65536 of a turn). Deltas add replay complexity for no M1 gain; revisit only if replay telemetry needs
++ them.
++4. **Actions are a bitfield of named flags.** The names and bits live in `packages/shared` as a frozen,
++ versioned list; adding a flag bumps the protocol version. Compact at 30 Hz, and M2's touch controls map
++ onto the same names.
++5. **No Rapier snapshots in M1.** Movement state lives in `PlayerMoveState`, not in the engine; prop and
++ island rollback are deferred past M1, so the snapshot API is not used and nothing is built for it.
++6. **Joints: functional equivalence in M1.** Welds hold rigid, ropes respect their length, springs return to
++ rest, each with a test; exact visual equivalence with Havok is refined in M2.
++7. **The intent enum lives in `packages/shared`**, the single source for protocol, gameplay and M2 touch;
++ `packages/gameplay/src/movement/buttons.ts` re-exports it.
++8. **`tryStepMove` gets a hysteresis band.** A contact within ±2 mm of the step threshold keeps the previous
++ tick's branch, so a millimetre of contact ambiguity between engines cannot flip step-up or auto-bhop.
++9. **The mock resolves a rounded capsule against a box edge** (a real convex sweep, not a padded AABB), with
++ the conformance suite's landing heights re-measured.
++
++## M1 step 4 finding: Rapier shape casts lose precision on very large colliders
++
++Measured while building the Rapier adapter (M1 steps 4–7). A diagonal `castShape` of the player capsule
++against a 400 m-long static box reports contact ~14 mm before the true face, while the same cast against a
++20 m box is exact to the f32 epsilon. The returned `time_of_impact` is inconsistent with the returned
++contact witnesses in that case. Consequences: determinism scene geometry is kept to sane extents, and the
++cross-engine tolerance is a per-tick bound, not bit-identity (decision 1).
++
## Deleted
The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegacy.ts`, `legacy_live_rows`,
ddiff --git a/packages/gameplay/src/movement/determinism.test.ts b/packages/gameplay/src/movement/determinism.test.ts
iindex af03486..ba8cdb8 100644
---- a/packages/gameplay/src/movement/determinism.test.ts
++++ b/packages/gameplay/src/movement/determinism.test.ts
@@@ -1,9 +1,6 @@
--import { readFile } from 'node:fs/promises'
--import { createRequire } from 'node:module'
--import { pathToFileURL } from 'node:url'
--import { vec3 } from '@openvibe/shared'
++import { vec3, type Vec3 } from '@openvibe/shared'
import { afterEach, describe, expect, it } from 'vitest'
--import { CollisionLayer, type PhysicsWorld } from '@openvibe/physics'
++import { CollisionLayer, type BodyDesc, type PhysicsWorld } from '@openvibe/physics'
import { createMockPhysicsWorld, mockCollisionQueries } from '@openvibe/physics/mock'
import { Buttons } from './buttons.js'
import type { CollisionQueries } from './collision.js'
@@@ -34,13 +31,12 @@ import { createMoveState, stepMovement, type MoveInput, type PlayerMoveState } f
* state in place through module-level scratch vectors, a test that re-uses
* one state object for both runs would "pass" trivially — running two
* genuinely separate states is the point.
-- * b. **Cross-world, within tolerance.** The same script against the Havok
++ * b. **Cross-world, within tolerance.** The same script against the Rapier
* adapter and the mock. Position ≤ 1 mm and velocity ≤ 1 cm/s per tick
* (ADR-0007 M1 decision 1). This is a TOLERANCE between two engines, not
-- * bit-identity, and it is the one that will force tuning of the mock or the
-- * adapter when Rapier lands. If the engines genuinely disagree past the
-- * tolerance, the tolerance is NOT to be loosened: the case names the first
-- * diverging tick and field, and the fix is the engine, not the number.
++ * bit-identity. If the engines genuinely disagree past the tolerance, the
++ * tolerance is NOT to be loosened: the case names the first diverging tick
++ * and field, and the fix is the engine, not the number.
* c. **Replay.** Inputs (seq, input) recorded for 600 ticks, state snapshotted
* at tick 300, replay 300..600 from the snapshot — equal hashes. This is
* the input-replay path (apps/client/src/game/localPlayer.ts and the
@@@ -139,13 +135,13 @@ interface Engine {
}
/**
-- * The wasm-backed adapter, loaded from `@openvibe/physics/havok` — the same
-- * entry point apps/server uses. It is loaded through a DYNAMIC import from
-- * the real path rather than a static one because `@babylonjs/havok` is a
-- * dependency of `@openvibe/physics`, not of `@openvibe/gameplay`, and this
++ * The wasm-backed adapter, loaded from `@openvibe/physics/rapier` — the same
++ * entry point apps/server and apps/client use. It is loaded through a DYNAMIC
++ * import rather than a static one because `@dimforge/rapier3d-deterministic-compat`
++ * is a dependency of `@openvibe/physics`, not of `@openvibe/gameplay`, and this
* package deliberately does not import an engine (ADR-0007 step 6 wants the
-- * gameplay kernel engine-free; a static import here would put Babylon back on
-- * the gameplay side of the boundary).
++ * gameplay kernel engine-free; a static import would put an engine back on the
++ * gameplay side of the boundary).
*
* This test is the ONLY case in the file that needs an engine, so when the
* load fails the failure is a skip, never a red suite: a missing wasm is an
@@@ -154,30 +150,35 @@ interface Engine {
*/
const notes: string[] = []
++/** The loaded engine, kept for the scene corpus below; null skips its cases. */
++let rapierEngine: { createWorld: (bodies: BodyDesc[]) => PhysicsWorld } | null = null
++
/**
* Loaded at collection time, not in `beforeAll`: the cross-world `describe`s
* below are registered from the engine list, and vitest collects before it
* runs any hook, so a `beforeAll` would always see an empty list.
++ *
++ * Loaded through `@openvibe/physics/rapier`, whose `loadRapier()` owns the
++ * dynamic import of the wasm build — the gameplay kernel never imports an
++ * engine (ADR-0007 step 6 wants it engine-free). The failure is a skip, never
++ * a red suite: a missing wasm is an environment fact, not a determinism defect.
*/
const engines: Engine[] = []
try {
-- // Resolved from inside @openvibe/physics, which is where @babylonjs/havok is
-- // a declared dependency. Asking for it from this package (which does not
-- // depend on it) would be a bare specifier vite cannot resolve — and adding
-- // the dependency here would put an engine back on the gameplay side of the
-- // boundary ADR-0007 is removing. The package's exports map allows only the
-- // bare specifier for the module itself, so resolve the bare name to a real
-- // path with the physics package's own require and import that file directly.
-- const fromPhysics = createRequire(createRequire(import.meta.url).resolve('@openvibe/physics'))
-- const havokEntry = fromPhysics.resolve('@babylonjs/havok')
-- const wasmPath = fromPhysics.resolve('@babylonjs/havok/lib/esm/HavokPhysics.wasm')
-- const { default: HavokPhysics } = await import(pathToFileURL(havokEntry).href)
-- const { createHeadlessHavokWorld } = await import('@openvibe/physics/havok')
-- const havok = await HavokPhysics({ wasmBinary: (await readFile(wasmPath)).buffer as ArrayBuffer })
++ const { createRapierWorld, loadRapier } = await import('@openvibe/physics/rapier')
++ const rapier = await loadRapier()
++ rapierEngine = {
++ createWorld: (bodies) => {
++ const world = createRapierWorld(rapier)
++ for (const body of bodies) world.addBody(body)
++ live.push(world)
++ return world
++ },
++ }
engines.push({
-- name: 'HavokWorld',
++ name: 'RapierWorld',
create: () => {
-- const world = createHeadlessHavokWorld(havok)
++ const world = createRapierWorld(rapier)
buildScriptedGeometry((desc) => world.addBody(desc))
live.push(world)
return {
@@@ -186,7 +187,7 @@ try {
}
},
})
-- notes.push('cross-world cases: MockPhysicsWorld vs HavokWorld (wasm loaded)')
++ notes.push('cross-world cases: MockPhysicsWorld vs RapierWorld (wasm loaded)')
} catch (err) {
notes.push(
'cross-world cases NOT RUN — the wasm engine could not be loaded: ' +
@@@ -340,38 +341,38 @@ describe('movement determinism: same runtime', () => {
* change of engine rather than a change of game, and it is the one case in
* this file that must be re-measured when the engine changes.
*
-- * ── MEASURED 2026-09-29, MockPhysicsWorld vs HavokWorld 1.3.10 ───────────
-- * The scripted run does NOT meet the 1 mm / 1 cm/s tolerance, and the reason
-- * is a branch, not floating-point noise — so no tolerance can absorb it. Two
-- * discrete events fork, in this order:
++ * ── MEASURED 2026-09-29, MockPhysicsWorld vs RapierWorld (deterministic 0.21.0) ──
++ * The 600-tick scripted run does NOT meet the 1 mm / 1 cm/s tolerance, and the
++ * reason is not floating-point noise:
*
-- * 1. **tick 102, first contact with the wall.** Both engines are grounded at
-- * the same point, but `slideMove`'s blocked→`tryStepMove` probe sweeps the
-- * capsule UP by `stepHeight` (0.45) and the wall is 3 m tall, so the probe
-- * is a face-on sweep at head height. Havok's convex-cast shape queries
-- * report the exact 0.4 m capsule width to its own f32; the mock's
-- * conservative AABB expansion reports it a millimetre wider, so the mock
-- * stops fraction 0.0 earlier and the two land 15 mm apart on x. Positions
-- * that were bit-identical to the tick before stop being.
-- * Measured: pos.x 1.507e-2, pos.z 1.998e-2, rising to 5.9e-2 by tick 106.
-- * 2. **tick 147, the auto-bhop.** The divergence from (1) is inside the
-- * 0.7 m/s `checkGround` release threshold, so one engine leaves the ground
-- * a tick before the other, lands a tick later, and the two never rejoin:
-- * pos.y 1.953e-3 at tick 147, 6.478e-3 by tick 151. Removing the wall does
-- * not remove this one — it simply moves it.
-- * 3. By tick 599 the accumulated difference is 460 m: the player has been
-- * falling through the world for four hundred ticks, and the last ticks of
-- * the script put the two hundreds of metres apart.
++ * 1. **The engines disagree by a few tenths of a millimetre per contact.**
++ * Rapier's `castShape` reports the wall face ~0.28 mm early (measured: the
++ * mock's contact fraction 0.51105 against Rapier's 0.49065 on a sweep that
++ * grazes the face at 13:1). On a face-on contact that is invisible; on a
++ * sweep nearly PARALLEL to the face a small normal-direction error becomes a
++ * large tangential one, so each sliding tick advances a few millimetres
++ * further in the mock than in Rapier and the gap accumulates. First
++ * divergence: tick 97–100 [pos.x, pos.z].
++ * 2. **A discrete branch then flips.** Once the accumulated gap reaches the
++ * step-up / auto-bhop thresholds, one engine takes the step or the hop and
++ * the other does not, and the runs never rejoin: max drift 4.6e+2 m at tick
++ * 599 (the bhop phase from tick 140 leaves one run falling through the
++ * world).
*
-- * That is the honest answer, and it is what step 4's Rapier swap is measured
-- * against. The tolerance assertion below is marked `it.fails`, as the brief
-- * directs, with the divergence named above and the numbers printed on every
-- * run; the tolerance itself is NOT widened to make it pass. The two cases
-- * beside it — the same runtime against a second real engine, and the
-- * resting-state control — both pass, so what is failing is specifically
-- * cross-engine contact geometry, not determinism itself.
++ * Consequences, kept honest rather than papered over:
++ * - The BIT-EXACT claim (two independently built Rapier worlds) IS met, on this
++ * script and on every scene of the corpus below. That is the claim prediction
++ * needs (ADR-0007 decision 1, "same runtime").
++ * - The 1 mm / 1 cm/s tolerance is a CROSS-RUNTIME bound for the SAME build
++ * (decision 1), and it holds there; between the mock and Rapier it holds only
++ * where no contact is sustained (flat ground: measured 1.2e-5 m over 90
++ * ticks). It is therefore asserted for the flat-ground scene and recorded as
++ * `it.fails` for the contact scenes, with the numbers printed on every run.
++ * Widening POSITION_TOLERANCE_M or VELOCITY_TOLERANCE_MPS would hide a real
++ * engine property, so it is not done.
*
-- * `engines` is where step 4 adds `createRapierWorld()`.
++ * `engines` is where the Rapier adapter is registered; the corpus describes its
++ * own geometry per scene.
*/
/** The worst cross-world drift over a full scripted run, and where it starts. */
@@@ -510,6 +511,202 @@ describe('movement determinism: cross-world within tolerance', () => {
for (const engine of engines) crossWorld(engine)
})
++// ── b2. the scene corpus (ADR-0007 M1 step 7) ────────────────────────────
++
++/**
++ * The five scenes the M1 definition of done names: flat ground, stairs, a wall
++ * slide, a terrain trimesh patch, a prop island. Each is static geometry plus
++ * one scripted 90-tick input run, so a scene failure names itself.
++ *
++ * Two claims are tested per scene:
++ *
++ * 1. **Two independently built worlds agree bit-for-bit.** Server and client
++ * build their own world from the same content and run the same input stream;
++ * this asserts the same `stateHash` on every tick, which is exactly
++ * "same input stream → same stateHash at the same tick". Green everywhere,
++ * the trimesh patch included — this is the claim prediction rests on.
++ * 2. **The mock and Rapier stay inside the cross-engine tolerance.** Measured
++ * below and printed every run; only flat ground meets it, and the block
++ * comment above says why the contact scenes do not.
++ */
++
++const corpusBox = (size: [number, number, number], pos: Vec3): BodyDesc => ({
++ shape: { type: 'box', size },
++ motion: 'static',
++ pos,
++ layer: CollisionLayer.Static,
++ collidesWith: CollisionLayer.Prop | CollisionLayer.Player,
++})
++
++const corpusFloor = (): BodyDesc => corpusBox([60, 1, 60], vec3(0, -0.5, 0))
++
++/** Four 0.2 m steps rising from z = 3 — what `tryStepMove` exists for. */
++const corpusStairs = (): BodyDesc[] =>
++ Array.from({ length: 4 }, (_, i) => corpusBox([6, 0.2, 0.4], vec3(0, 0.1 + i * 0.2, 3 + i * 0.4)))
++
++/** A 4 m ramp patch of two triangles — the shape the terrain layer builds. */
++const corpusTerrain = (): BodyDesc => ({
++ shape: {
++ type: 'trimesh',
++ positions: new Float32Array([-4, 0, 3, 4, 0, 3, 4, 0.4, 7, -4, 0.4, 7]),
++ indices: new Uint32Array([0, 1, 2, 0, 2, 3]),
++ },
++ motion: 'static',
++ pos: vec3(0, 0, 0),
++ layer: CollisionLayer.Static,
++ collidesWith: CollisionLayer.Prop | CollisionLayer.Player,
++})
++
++/**
++ * A 3x3 island of boxes. Static, because the mock has no body-body contacts:
++ * what a prop island exercises at the seam is its collision silhouettes (box
++ * faces, edges and corners), not the stacking the mock cannot model.
++ */
++const corpusIsland = (): BodyDesc[] => {
++ const out: BodyDesc[] = []
++ for (let x = -1; x <= 1; x++)
++ for (let z = -1; z <= 1; z++) out.push(corpusBox([0.8, 0.8, 0.8], vec3(x * 0.9, 0.4, 6 + z * 0.9)))
++ return out
++}
++
++const corpusTicks = (n: number, f: (t: number) => MoveInput): RecordedInput[] =>
++ Array.from({ length: n }, (_, t) => ({ seq: t, input: f(t) }))
++
++const corpusWalk = (_t: number): MoveInput => ({ moveX: 0, moveZ: 1, yaw: 0, pitch: 0, buttons: 0 })
++/** Walks into the wall at x = 4 at an angle, so the capsule has to slide along it. */
++const corpusIntoWall = (t: number): MoveInput => ({
++ moveX: 0,
++ moveZ: 1,
++ yaw: 0.35,
++ pitch: 0,
++ buttons: t >= 45 ? Buttons.Sprint : 0,
++})
++
++interface CorpusScene {
++ name: string
++ bodies: BodyDesc[]
++ script: RecordedInput[]
++ /** The mock has no triangle queries, so the terrain patch runs on real worlds only. */
++ mockExpressible: boolean
++}
++
++const CORPUS: CorpusScene[] = [
++ { name: 'flat ground', bodies: [corpusFloor()], script: corpusTicks(90, corpusWalk), mockExpressible: true },
++ {
++ name: 'stairs',
++ bodies: [corpusFloor(), ...corpusStairs()],
++ script: corpusTicks(90, corpusWalk),
++ mockExpressible: true,
++ },
++ {
++ name: 'wall slide',
++ bodies: [corpusFloor(), corpusBox([1, 3, 20], vec3(4, 1.5, 0))],
++ script: corpusTicks(90, corpusIntoWall),
++ mockExpressible: true,
++ },
++ {
++ name: 'terrain trimesh patch',
++ bodies: [corpusFloor(), corpusTerrain()],
++ script: corpusTicks(90, corpusWalk),
++ mockExpressible: false,
++ },
++ {
++ name: 'prop island',
++ bodies: [corpusFloor(), ...corpusIsland()],
++ script: corpusTicks(90, corpusWalk),
++ mockExpressible: true,
++ },
++]
++
++function mockQueries(bodies: BodyDesc[]): CollisionQueries {
++ const world = createMockPhysicsWorld()
++ for (const body of bodies) world.addBody(body)
++ live.push(world)
++ return mockCollisionQueries(world, MOVEMENT_MASK)
++}
++
++function engineQueries(scene: CorpusScene): CollisionQueries {
++ const world = rapierEngine!.createWorld(scene.bodies)
++ return {
++ sweepCapsule: (from, to, radius, height) =>
++ world.sweepCapsule(from, to, radius, height, MOVEMENT_MASK),
++ }
++}
++
++/** Runs one script through two queries, tick by tick, and reports the worst drift. */
++function measureScriptPair(
++ a: CollisionQueries,
++ b: CollisionQueries,
++ script: RecordedInput[],
++): { position: number; velocity: number; firstBadTick: number } {
++ const sa = spawn()
++ const sb = spawn()
++ let position = 0
++ let velocity = 0
++ let firstBadTick = -1
++ for (let t = 0; t < script.length; t++) {
++ stepMovement(sa, script[t]!.input, P, a, DT)
++ stepMovement(sb, script[t]!.input, P, b, DT)
++ const d = divergence(sa, sb)
++ if (d.maxPosition > position) position = d.maxPosition
++ if (d.maxVelocity > velocity) velocity = d.maxVelocity
++ if (!d.equal && firstBadTick < 0) firstBadTick = t
++ }
++ return { position, velocity, firstBadTick }
++}
++
++describe('movement determinism: the scene corpus (ADR-0007 M1 step 7)', () => {
++ for (const scene of CORPUS) {
++ it(`${scene.name}: the same input stream hashes identically in two independently built worlds`, () => {
++ expect(rapierEngine, notes.join('; ')).not.toBeNull()
++ const a = runScript(engineQueries(scene), scene.script)
++ const b = runScript(engineQueries(scene), scene.script)
++ for (let t = 0; t < scene.script.length; t++) {
++ expect(a[t], `tick ${t}: ${a[t]} vs ${b[t]}`).toBe(b[t])
++ }
++ })
++ }
++
++ it('measures the mock-vs-Rapier drift for every scene the mock can express', () => {
++ expect(rapierEngine, notes.join('; ')).not.toBeNull()
++ const lines: string[] = []
++ for (const scene of CORPUS) {
++ if (!scene.mockExpressible) continue
++ const m = measureScriptPair(mockQueries(scene.bodies), engineQueries(scene), scene.script)
++ lines.push(
++ `${scene.name}: pos ${m.position.toExponential(3)} m, vel ${m.velocity.toExponential(3)} m/s ` +
++ `(first tick ${m.firstBadTick})`,
++ )
++ }
++ console.log(`[determinism corpus] ${lines.join(' | ')}`)
++ expect(lines.length).toBeGreaterThan(0)
++ })
++
++ it('holds the cross-engine tolerance on flat ground', () => {
++ expect(rapierEngine, notes.join('; ')).not.toBeNull()
++ const scene = CORPUS[0] as CorpusScene
++ const m = measureScriptPair(mockQueries(scene.bodies), engineQueries(scene), scene.script)
++ expect(m.position).toBeLessThanOrEqual(POSITION_TOLERANCE_M)
++ expect(m.velocity).toBeLessThanOrEqual(VELOCITY_TOLERANCE_MPS)
++ })
++
++ it.fails('holds the cross-engine tolerance on the contact scenes', () => {
++ // Stairs, the wall slide and the prop island all exceed it, for the reason
++ // measured in the block comment above (a few tenths of a millimetre of
++ // contact slop per touch, amplified tangentially by a near-parallel slide,
++ // then a discrete branch flip). Inverted so the suite stays green while the
++ // claim is visibly unproven; if it ever starts passing, vitest flips this
++ // line to FAILING and `it.fails` comes off.
++ expect(rapierEngine, notes.join('; ')).not.toBeNull()
++ for (const scene of CORPUS) {
++ if (!scene.mockExpressible) continue
++ const m = measureScriptPair(mockQueries(scene.bodies), engineQueries(scene), scene.script)
++ expect(m.position, `${scene.name} position`).toBeLessThanOrEqual(POSITION_TOLERANCE_M)
++ expect(m.velocity, `${scene.name} velocity`).toBeLessThanOrEqual(VELOCITY_TOLERANCE_MPS)
++ }
++ })
++})
++
// ── c. replay ─────────────────────────────────────────────────────────────
describe('movement determinism: replay from a snapshot', () => {
ddiff --git a/packages/physics/package.json b/packages/physics/package.json
iindex 90a610e..2ec9ba8 100644
---- a/packages/physics/package.json
++++ b/packages/physics/package.json
@@@ -5,15 +5,14 @@
"type": "module",
"exports": {
".": "./src/index.ts",
-- "./havok": "./src/havok/index.ts",
++ "./rapier": "./src/rapier/index.ts",
"./mock": "./src/mock.ts"
},
"scripts": {
"build": "tsc --build"
},
"dependencies": {
-- "@babylonjs/core": "^9.20.0",
-- "@babylonjs/havok": "^1.3.10",
++ "@dimforge/rapier3d-deterministic-compat": "0.21.0",
"@openvibe/shared": "workspace:*"
}
}
ddiff --git a/packages/physics/src/conformance.test.ts b/packages/physics/src/conformance.test.ts
iindex da303e4..3730f58 100644
---- a/packages/physics/src/conformance.test.ts
++++ b/packages/physics/src/conformance.test.ts
@@@ -1,9 +1,6 @@
import { readFile } from 'node:fs/promises'
--import { createRequire } from 'node:module'
import { quat, vec3, type Quat, type Vec3 } from '@openvibe/shared'
import { beforeAll, describe, expect, it } from 'vitest'
--import HavokPhysics from '@babylonjs/havok'
--import { createHeadlessHavokWorld } from './havok/index.js'
import {
MOCK_GRAVITY,
MOCK_SETTLE_ANGULAR,
@@@ -11,62 +8,46 @@ import {
MOCK_TICK,
createMockPhysicsWorld,
} from './mock.js'
++import { createRapierWorld, loadRapier, type RapierModule } from './rapier/index.js'
import { CollisionLayer, type BodyDesc, type PhysicsWorld } from './types.js'
/**
* Seam conformance.
*
* Every case runs against whatever PhysicsWorld factory is handed to
-- * `conformance()`, so a second engine (ADR-0007 decision 2 names Rapier) can be
-- * held to exactly the behaviour Havok has today. Nothing in this file may reach
-- * around the seam: no Babylon, no Havok, no assumption about internals beyond
-- * what `PhysicsWorld` promises.
++ * `conformance()`. The real engine is Rapier (ADR-0007 decision 2); the mock is
++ * the wasm-free oracle. Nothing in this file may reach around the seam: no
++ * engine type, no assumption about internals beyond what `PhysicsWorld`
++ * promises.
*
-- * ── Constants, read off havokWorld.ts rather than the brief ──────────────
-- * - gravity (0, -16.5, 0) havokWorld.ts:588
-- * - material friction 0.75 / static 0.85 / restitution 0.05 :123
-- * - linear damping 0.05, angular 0.6 :131-132
-- * - default mass 10 kg :128
-- * - settle thresholds 0.05 m/s, 0.15 rad/s :72-73 (squared compare, angular `<=`)
++ * ── Constants the adapters are built from ────────────────────────────────
++ * - gravity (0, -16.5, 0) rapierWorld.ts RAPIER_GRAVITY
++ * - material friction 0.75, restitution 0.05 rapierWorld.ts
++ * - linear damping 0.05, angular 0.6 rapierWorld.ts
++ * - default mass 10 kg rapierWorld.ts
++ * - settle thresholds 0.05 m/s, 0.15 rad/s (squared compare, angular `<=`)
*
* ── Behaviour asserted here that is NOT in the seam doc comment ──────────
-- * These are properties of today's Havok adapter, documented so the Rapier port
-- * reproduces them rather than trips over them:
++ * The Rapier adapter sets these explicitly and this suite holds it to them:
*
-- * 1. **`step(dt)` does not honour its argument.** `HavokWorld.step` calls
-- * `plugin.executeStep(dt, bodies)`, and the Havok plugin resolves the world
-- * step time as `this._useDeltaForWorldStep ? delta : this._fixedTimeStep`
-- * with `_useDeltaForWorldStep` false by default — so every step() advances a
-- * fixed 1/60 s of simulation regardless of the dt passed in. Measured: 30
-- * step(1/30) and 30 step(1/15) both yield v.y = -8.14 m/s, while 60
-- * step(1/60) yields -16.09. The seam comment says "advances by a fixed dt",
-- * which is the *intent*; the caller-side 30 Hz tick is what the game relies
-- * on and it is unaffected, but a Rapier adapter that actually honours dt
-- * would be ~2× faster per tick at 30 Hz and would change every simulation
-- * constant tuned against Havok. Listed for Opus.
++ * 1. **`step(dt)` honours its argument.** Rapier's integration parameters take
++ * the dt directly, so 30 steps of 1/30 and 60 steps of 1/60 are the same
++ * simulated second (the mock agrees; Havok did not, which is why the adapter
++ * exposes `stepSeconds` and the gravity cases convert through it).
* 2. **Waking or settling a body at the exact threshold.** The code compares
-- * `len² > threshold²` for linear (so a body moving at exactly 0.05 m/s is
-- * reported *unsettled* by Havok's float storage, though the comparison says
-- * settled) and `<=` for angular. The mock implements the comparison as
-- * written; this suite therefore probes just under and just over each
-- * threshold and does not pin the exact-boundary behaviour.
-- * 3. **Raycast filtering is by the body's own `layer`.** `plugin.raycast` is
-- * called with `membership: 0xffffffff, collideWith: collidesWith`
-- * (havokWorld.ts:415-418), but the shape filter is a membership/collide
-- * pair, so a body is hit when `rayMask & body.layer` and `body.layer &
-- * body.collidesWith` — the body's `collidesWith` must include its own layer
-- * for the pair to pass. Measured: layer Player / collidesWith Static is hit
-- * by mask 4 but not by mask 1, while layer Static / collidesWith Prop is hit
-- * by mask 1, 2, 3 and 7. The mock filters on `rayMask & body.layer` only,
-- * which is a superset — see "what the mock does not model".
++ * `len² > threshold²` for linear and `<=` for angular; this suite probes just
++ * under and just over each threshold and does not pin exact-boundary float
++ * behaviour.
++ * 3. **Raycast filtering is by the target's own `layer`.** The query's
++ * membership is all-on and the filter is the caller's mask, so a body is hit
++ * exactly when `rayMask & body.layer`. The mock filters the same way.
* 4. **A welded body resists velocity changes for a few steps.** `setLinearVelocity`
* on one half of a weld writes the velocity, but the joint's relative-pose
* preservation pulls the pair back over the next couple of steps. Asserted
* here as "the pair stays together", not as free sliding.
-- * 5. **`isSettled`, `wake` and `applyForce` ignore non-dynamic bodies**
-- * (havokWorld.ts:225, :233, :406), and `removeBody` / `removeConstraint` on
-- * an unknown id are silent no-ops (:142, :399) while every other body
-- * accessor throws `unknown physics body <id>` (:482).
++ * 5. **`isSettled`, `wake` and `applyForce` ignore non-dynamic bodies**, and
++ * `removeBody` / `removeConstraint` on an unknown id are silent no-ops while
++ * every other body accessor throws `unknown physics body <id>`.
*/
const DT = 1 / 30
@@@ -90,20 +71,17 @@ interface Adapter {
stepSeconds: number
}
--/** HavokWorld over a NullEngine scene — the exact headless server code path. */
--let havok: unknown
++/** RapierWorld over the deterministic wasm build — the exact headless server code path. */
++let rapier: RapierModule
beforeAll(async () => {
-- const require = createRequire(import.meta.url)
-- const wasmPath = require.resolve('@babylonjs/havok/lib/esm/HavokPhysics.wasm')
-- havok = await HavokPhysics({ wasmBinary: (await readFile(wasmPath)).buffer as ArrayBuffer })
++ rapier = await loadRapier()
}, 60_000)
--const havokAdapter: Adapter = {
-- name: 'HavokWorld',
-- create: () => createHeadlessHavokWorld(havok),
-- // Measured (header note 1): the plugin ignores the dt passed to step() and
-- // advances its own fixed 1/60 s.
-- stepSeconds: 1 / 60,
++const rapierAdapter: Adapter = {
++ name: 'RapierWorld',
++ create: () => createRapierWorld(rapier),
++ // Rapier honours the dt passed to step(); 30 Hz is the tick the game runs at.
++ stepSeconds: DT,
}
/**
@@@ -775,7 +753,10 @@ function conformance(a: Adapter): void {
stepFor(w, 60)
w.getTransform(a, pa, rot())
w.getTransform(b, pb, rot())
-- expect(pb.y - pa.y).toBeCloseTo(0.5, 1)
++ // "Stays rigid" is the claim worth pinning: the weld keeps the pair's
++ // separation at the creation-time offset. (A Rapier fixed joint also
++ // allows the island to rotate, so the offset need not stay vertical.)
++ expect(Math.hypot(pb.x - pa.x, pb.y - pa.y, pb.z - pa.z)).toBeCloseTo(0.5, 1)
expect(pb.z - pa.z).toBeCloseTo(0, 1)
close()
},
@@@ -784,20 +765,10 @@ function conformance(a: Adapter): void {
it('removing a weld frees the follower', () => {
// What "freed" means at the seam: once the joint is gone the two bodies
// are independent, so a velocity change on the follower moves the
-- // follower and leaves the driver exactly where it was.
-- //
-- // It deliberately does NOT assert that the follower then rests at some
-- // particular height. On a stacked pair resting on the ground that is
-- // engine behaviour and the two engines disagree: the mock has no
-- // body-body contacts, so the follower drops straight through back to the
-- // floor (0.25) inside this window, while Havok's solver keeps it on the
-- // driver and carries it to 1.609. Both are "no longer welded", which is
-- // the property that belongs to the seam; a per-engine landing height is
-- // pinned separately in each engine branch. Measured: with the pair
-- // grounded and the follower kicked to +6 m/s after removal, Havok leaves
-- // the driver at 0.250 and the follower at 1.609 after 30 steps; the mock
-- // leaves the driver at 0.250 and the follower at 0.250, having already
-- // arced up and landed back on the floor.
++ // follower and leaves the driver exactly where it was. The follower is
++ // kicked HORIZONTALLY so the claim does not depend on either engine's
++ // contact solver: under a weld it could not move without dragging the
++ // driver, and the mock's missing body-body contacts cannot mask it.
const w = open()
w.addBody(groundDesc())
const a = w.addBody(dynamicBoxDesc(vec3(0, 0.25, 0), 0.5, 10))
@@@ -808,17 +779,17 @@ function conformance(a: Adapter): void {
const pa = pos()
w.getTransform(a, pa, rot())
expect(pa.y).toBeCloseTo(0.25, 2)
-- w.setLinearVelocity(b, vec3(0, 6, 0))
++ w.setLinearVelocity(b, vec3(0, 0, 6))
stepFor(w, 30)
const pa2 = pos()
const pb = pos()
w.getTransform(a, pa2, rot())
w.getTransform(b, pb, rot())
-- // The driver did not move at all, and the follower has left the pose the
-- // weld held it in (0.75) — up on Havok, down through the missing contact
-- // on the mock. A still-welded follower could not do either.
++ // The driver did not move, and the follower slid away — a still-welded
++ // follower could not do either.
expect(pa2.y).toBeCloseTo(0.25, 2)
-- expect(Math.abs(pb.y - 0.75)).toBeGreaterThan(0.1)
++ expect(pa2.z).toBeCloseTo(0, 2)
++ expect(Math.abs(pb.z)).toBeGreaterThan(0.1)
close()
})
@@@ -1364,11 +1335,10 @@ function conformance(a: Adapter): void {
expect(dead).toEqual([])
})
} else {
-- it('a freed follower stays on the driver on Havok’s solver', () => {
-- // The Havok half of "removing a weld frees the follower": the contact
-- // solver keeps the upper box stacked on the lower one, so after the same
-- // 30-step window the shared case finds it up at ~1.6 rather than back
-- // down on the floor. Measured: 1.609.
++ it('a freed follower lands back on the driver via the contact solver', () => {
++ // Rapier, like Havok, has a contact solver: after the follower is kicked
++ // vertically off a freed weld it lands back on top of the lower box
++ // (measured ~0.75), rather than falling through as the mock does.
const w = open()
w.addBody(groundDesc())
const a = w.addBody(dynamicBoxDesc(vec3(0, 0.25, 0), 0.5, 10))
@@@ -1377,16 +1347,17 @@ function conformance(a: Adapter): void {
stepFor(w, 60)
w.removeConstraint(weld)
w.setLinearVelocity(b, vec3(0, 6, 0))
++ // 30 steps at 30 Hz is one second: the follower arcs up ~1 m and lands.
stepFor(w, 30)
const pb = pos()
w.getTransform(b, pb, rot())
-- expect(pb.y).toBeGreaterThan(0.75)
-- expect(pb.y).toBeLessThan(2)
++ expect(pb.y).toBeGreaterThan(0.5)
++ expect(pb.y).toBeLessThan(1.2)
close()
})
it.skipIf(maybe('step advances'))(
-- 'step advances a fixed 1/60 s regardless of the dt passed (Havok plugin)',
++ 'step advances exactly the dt it is given',
() => {
const probe = (dt: number, ticks: number) => {
const w = open()
@@@ -1397,15 +1368,16 @@ function conformance(a: Adapter): void {
close()
return v.y
}
-- // 30 steps at 30 Hz and 30 steps at 15 Hz are the same amount of
-- // simulated time, because the plugin uses its own fixed step time.
-- expect(probe(1 / 30, 30)).toBeCloseTo(probe(1 / 15, 30), 3)
-- // An extra second of simulation is very nearly twice the velocity.
-- expect(probe(1 / 60, 120) / probe(1 / 60, 60)).toBeGreaterThan(1.9)
-- expect(probe(1 / 60, 120) / probe(1 / 60, 60)).toBeLessThan(2.1)
++ // Rapier honours the dt it is given: 30 steps of 1/30 s and 60 steps
++ // of 1/60 s are the same simulated second (the damping is applied per
++ // step, so they agree to within a few mm/s, not bit-exactly).
++ expect(probe(1 / 30, 30)).toBeCloseTo(probe(1 / 60, 60), 1)
++ // An extra simulated second is very nearly twice the velocity.
++ expect(probe(1 / 30, 60) / probe(1 / 30, 30)).toBeGreaterThan(1.9)
++ expect(probe(1 / 30, 60) / probe(1 / 30, 30)).toBeLessThan(2.1)
// One simulated second of this gravity: about -16.5 m/s.
-- expect(probe(1 / 30, 60)).toBeLessThan(-15)
-- expect(probe(1 / 30, 60)).toBeGreaterThan(-17)
++ expect(probe(1 / 30, 30)).toBeLessThan(-15)
++ expect(probe(1 / 30, 30)).toBeGreaterThan(-17)
},
)
@@@ -1532,5 +1504,5 @@ function conformance(a: Adapter): void {
})
}
--conformance(havokAdapter)
++conformance(rapierAdapter)
conformance(mockAdapter)
ddiff --git a/packages/physics/src/havok/havokWorld.ts b/packages/physics/src/havok/havokWorld.ts
ddeleted file mode 100644
iindex 271fc65..0000000
---- a/packages/physics/src/havok/havokWorld.ts
++++ /dev/null
@@@ -1,604 +0,0 @@
--import { NullEngine } from '@babylonjs/core/Engines/nullEngine.js'
--import { Quaternion, Vector3 } from '@babylonjs/core/Maths/math.vector.js'
--import { TransformNode } from '@babylonjs/core/Meshes/transformNode.js'
--import { PhysicsRaycastResult } from '@babylonjs/core/Physics/physicsRaycastResult.js'
--import { ShapeCastResult } from '@babylonjs/core/Physics/shapeCastResult.js'
--import { HavokPlugin } from '@babylonjs/core/Physics/v2/Plugins/havokPlugin.js'
--import {
-- PhysicsActivationControl,
-- PhysicsMotionType,
-- PhysicsPrestepType,
--} from '@babylonjs/core/Physics/v2/IPhysicsEnginePlugin.js'
--import { PhysicsBody } from '@babylonjs/core/Physics/v2/physicsBody.js'
--import type { PhysicsShape } from '@babylonjs/core/Physics/v2/physicsShape.js'
--import {
-- PhysicsShapeBox,
-- PhysicsShapeCapsule,
-- PhysicsShapeCylinder,
-- PhysicsShapeMesh,
-- PhysicsShapeSphere,
--} from '@babylonjs/core/Physics/v2/physicsShape.js'
--import { Mesh } from '@babylonjs/core/Meshes/mesh.js'
--import { VertexData } from '@babylonjs/core/Meshes/mesh.vertexData.js'
--import {
-- Physics6DoFConstraint,
-- PhysicsConstraint,
--} from '@babylonjs/core/Physics/v2/physicsConstraint.js'
--import {
-- PhysicsConstraintAxis,
-- PhysicsConstraintMotorType,
-- PhysicsConstraintType,
--} from '@babylonjs/core/Physics/v2/IPhysicsEnginePlugin.js'
--import { Scene } from '@babylonjs/core/scene.js'
--import '@babylonjs/core/Physics/joinedPhysicsEngineComponent.js'
--import type { Quat, Vec3 } from '@openvibe/shared'
--import type {
-- BodyDesc,
-- BodyId,
-- ConstraintDesc,
-- ConstraintId,
-- ConstraintMotor,
-- MotionType,
-- PhysicsWorld,
-- RayHit,
-- ShapeDesc,
-- SweepHit,
--} from '../types.js'
--import { CollisionLayer } from '../types.js'
--
--/**
-- * Havok (via Babylon Physics V2) implementation of PhysicsWorld.
-- *
-- * The same adapter runs headless on the server (NullEngine scene, never
-- * rendered) and inside the client's rendered scene. Stepping is always
-- * manual through `plugin.executeStep` from the fixed-timestep loop — the
-- * scene's own render-driven physics stepping is disabled so simulation can
-- * never couple to frame rate.
-- */
--
--interface BodyRecord {
-- body: PhysicsBody
-- node: TransformNode
-- shape: PhysicsShape
-- motion: MotionType
--}
--
--const MOTION_MAP: Record<MotionType, PhysicsMotionType> = {
-- dynamic: PhysicsMotionType.DYNAMIC,
-- static: PhysicsMotionType.STATIC,
-- kinematic: PhysicsMotionType.ANIMATED,
--}
--
--const SETTLE_LIN_SQ = 0.05 * 0.05
--const SETTLE_ANG_SQ = 0.15 * 0.15
--
--export class HavokWorld implements PhysicsWorld {
-- private nextId: BodyId = 1
-- private nextConstraintId: ConstraintId = 1
-- private readonly bodies = new Map<BodyId, BodyRecord>()
-- private readonly constraints = new Map<
-- ConstraintId,
-- { constraint: PhysicsConstraint; type: ConstraintDesc['type'] }
-- >()
-- private readonly bodyIds = new WeakMap<PhysicsBody, BodyId>()
-- private bodyList: PhysicsBody[] = []
-- private bodyListDirty = false
--
-- // Scratch objects — adapter methods are not re-entrant.
-- private readonly _v1 = new Vector3()
-- private readonly _v2 = new Vector3()
-- private readonly _q1 = new Quaternion()
-- private readonly _rayResult = new PhysicsRaycastResult()
-- private readonly _castInput = new ShapeCastResult()
-- private readonly _castHit = new ShapeCastResult()
-- private readonly sweepShapes = new Map<string, PhysicsShapeCapsule>()
--
-- constructor(
-- private readonly scene: Scene,
-- private readonly plugin: HavokPlugin,
-- private readonly ownsScene: boolean,
-- ) {}
--
-- step(dt: number): void {
-- if (this.bodyListDirty) {
-- this.bodyList = [...this.bodies.values()].map((r) => r.body)
-- this.bodyListDirty = false
-- }
-- this.plugin.executeStep(dt, this.bodyList)
-- }
--
-- addBody(desc: BodyDesc): BodyId {
-- const id = this.nextId++
-- const node = new TransformNode(`body:${id}`, this.scene)
-- node.position.set(desc.pos.x, desc.pos.y, desc.pos.z)
-- node.rotationQuaternion = desc.rot
-- ? new Quaternion(desc.rot.x, desc.rot.y, desc.rot.z, desc.rot.w)
-- : Quaternion.Identity()
--
-- const shape = this.makeShape(desc.shape)
-- shape.filterMembershipMask = desc.layer
-- shape.filterCollideMask = desc.collidesWith
-- // Grippy, dead-on-impact surfaces: Source props bite the ground instead
-- // of gliding (Havok defaults are ice-like) and barely bounce.
-- shape.material = { friction: 0.75, staticFriction: 0.85, restitution: 0.05 }
--
-- const body = new PhysicsBody(node, MOTION_MAP[desc.motion], false, this.scene)
-- body.shape = shape
-- if (desc.motion === 'dynamic') {
-- body.setMassProperties({ mass: desc.massKg ?? 10 })
-- // Light linear + strong angular damping kill the floaty drift and
-- // endless micro-rolling that read as "low gravity".
-- body.setLinearDamping(0.05)
-- body.setAngularDamping(0.6)
-- }
--
-- this.bodies.set(id, { body, node, shape, motion: desc.motion })
-- this.bodyIds.set(body, id)
-- this.bodyListDirty = true
-- return id
-- }
--
-- removeBody(id: BodyId): void {
-- const rec = this.bodies.get(id)
-- if (!rec) return
-- rec.body.dispose()
-- rec.shape.dispose()
-- rec.node.dispose()
-- this.bodies.delete(id)
-- this.bodyListDirty = true
-- }
--
-- getTransform(id: BodyId, outPos: Vec3, outRot: Quat): void {
-- const rec = this.mustGet(id)
-- const p = rec.node.position
-- outPos.x = p.x
-- outPos.y = p.y
-- outPos.z = p.z
-- const q = rec.node.rotationQuaternion
-- if (q) {
-- outRot.x = q.x
-- outRot.y = q.y
-- outRot.z = q.z
-- outRot.w = q.w
-- }
-- }
--
-- setTransform(id: BodyId, pos: Vec3, rot?: Quat): void {
-- const rec = this.mustGet(id)
-- rec.node.position.set(pos.x, pos.y, pos.z)
-- if (rot && rec.node.rotationQuaternion) {
-- rec.node.rotationQuaternion.set(rot.x, rot.y, rot.z, rot.w)
-- }
-- if (rec.motion === 'kinematic') {
-- // ANIMATED bodies chase the node target with computed velocity so they
-- // push dynamic bodies convincingly.
-- this._v1.set(pos.x, pos.y, pos.z)
-- const q = rec.node.rotationQuaternion ?? Quaternion.Identity()
-- rec.body.setTargetTransform(this._v1, q)
-- } else {
-- // CRITICAL: with the default prestep (DISABLED),
-- // setPhysicsBodyTransformation is a silent no-op — static mirror
-- // bodies would never actually move. Flip to TELEPORT for the call.
-- rec.body.setPrestepType(PhysicsPrestepType.TELEPORT)
-- this.plugin.setPhysicsBodyTransformation(rec.body, rec.node)
-- rec.body.setPrestepType(PhysicsPrestepType.DISABLED)
-- this.wake(id)
-- }
-- }
--
-- setMotionType(id: BodyId, motion: MotionType): void {
-- const rec = this.mustGet(id)
-- if (rec.motion === motion) return
-- rec.body.setMotionType(MOTION_MAP[motion])
-- rec.motion = motion
-- if (motion === 'dynamic') {
-- this.wake(id)
-- }
-- }
--
-- getLinearVelocity(id: BodyId, out: Vec3): void {
-- this.mustGet(id).body.getLinearVelocityToRef(this._v1)
-- out.x = this._v1.x
-- out.y = this._v1.y
-- out.z = this._v1.z
-- }
--
-- setLinearVelocity(id: BodyId, v: Vec3): void {
-- this._v1.set(v.x, v.y, v.z)
-- this.mustGet(id).body.setLinearVelocity(this._v1)
-- }
--
-- getAngularVelocity(id: BodyId, out: Vec3): void {
-- this.mustGet(id).body.getAngularVelocityToRef(this._v1)
-- out.x = this._v1.x
-- out.y = this._v1.y
-- out.z = this._v1.z
-- }
--
-- setAngularVelocity(id: BodyId, v: Vec3): void {
-- this._v1.set(v.x, v.y, v.z)
-- this.mustGet(id).body.setAngularVelocity(this._v1)
-- }
--
-- isSettled(id: BodyId): boolean {
-- const rec = this.mustGet(id)
-- if (rec.motion !== 'dynamic') return true
-- rec.body.getLinearVelocityToRef(this._v1)
-- if (this._v1.lengthSquared() > SETTLE_LIN_SQ) return false
-- rec.body.getAngularVelocityToRef(this._v1)
-- return this._v1.lengthSquared() <= SETTLE_ANG_SQ
-- }
--
-- wake(id: BodyId): void {
-- const rec = this.mustGet(id)
-- if (rec.motion !== 'dynamic') return
-- // Toggling activation control is the portable way to wake a Havok body
-- // without disturbing its velocities.
-- this.plugin.setActivationControl(rec.body, PhysicsActivationControl.ALWAYS_ACTIVE)
-- this.plugin.setActivationControl(rec.body, PhysicsActivationControl.SIMULATION_CONTROLLED)
-- }
--
-- addConstraint(desc: ConstraintDesc): ConstraintId {
-- const recA = this.mustGet(desc.bodyA)
-- const recB = this.mustGet(desc.bodyB)
-- const constraint = this.buildConstraint(desc, recA, recB)
-- recA.body.addConstraint(recB.body, constraint)
-- // Motors must be configured AFTER the joint exists in the engine.
-- if ((desc.type === 'hinge' || desc.type === 'slider') && desc.motor) {
-- const axis =
-- desc.type === 'hinge' ? PhysicsConstraintAxis.ANGULAR_X : PhysicsConstraintAxis.LINEAR_X
-- const c = constraint as Physics6DoFConstraint
-- c.setAxisMotorType(axis, PhysicsConstraintMotorType.VELOCITY)
-- c.setAxisMotorTarget(axis, desc.motor.targetVelocity)
-- c.setAxisMotorMaxForce(axis, desc.motor.maxForce)
-- }
-- if (desc.type === 'hinge' && desc.friction !== undefined) {
-- ;(constraint as Physics6DoFConstraint).setAxisFriction(
-- PhysicsConstraintAxis.ANGULAR_X,
-- desc.friction,
-- )
-- }
-- const id = this.nextConstraintId++
-- this.constraints.set(id, { constraint, type: desc.type })
-- if (recA.motion === 'dynamic') this.wake(desc.bodyA)
-- if (recB.motion === 'dynamic') this.wake(desc.bodyB)
-- return id
-- }
--
-- private buildConstraint(
-- desc: ConstraintDesc,
-- recA: BodyRecord,
-- recB: BodyRecord,
-- ): PhysicsConstraint {
-- switch (desc.type) {
-- case 'weld': {
-- const rotA = recA.node.rotationQuaternion ?? Quaternion.Identity()
-- const rotB = recB.node.rotationQuaternion ?? Quaternion.Identity()
-- // Anchor frames preserving the CURRENT relative pose:
-- // pivotA = B's origin in A's local space; axes = B's basis in A's space.
-- const invRotA = rotA.conjugate()
-- const relRot = invRotA.multiply(rotB)
-- const pivotA = recB.node.position
-- .subtract(recA.node.position)
-- .applyRotationQuaternion(invRotA)
-- const axisA = new Vector3(1, 0, 0).applyRotationQuaternion(relRot)
-- const perpA = new Vector3(0, 1, 0).applyRotationQuaternion(relRot)
-- return new PhysicsConstraint(
-- PhysicsConstraintType.LOCK,
-- {
-- pivotA,
-- pivotB: Vector3.Zero(),
-- axisA,
-- axisB: new Vector3(1, 0, 0),
-- perpAxisA: perpA,
-- perpAxisB: new Vector3(0, 1, 0),
-- collision: false,
-- },
-- this.scene,
-- )
-- }
-- case 'rope':
-- // A pure max-distance tether: slack below `length`, taut at it.
-- // The pair keeps colliding by default — a crate on a rope bonks
-- // the post — unless a rigid joint already links them.
-- return new PhysicsConstraint(
-- PhysicsConstraintType.DISTANCE,
-- {
-- pivotA: toBjs(desc.anchorA),
-- pivotB: toBjs(desc.anchorB),
-- maxDistance: desc.length,
-- collision: desc.collision ?? true,
-- },
-- this.scene,
-- )
-- case 'spring':
-- // Soft equality at restLength: force = stiffness·error − damping·vel.
-- // (Built as the 6DoF SpringConstraint does, plus the collision flag.)
-- return new Physics6DoFConstraint(
-- {
-- pivotA: toBjs(desc.anchorA),
-- pivotB: toBjs(desc.anchorB),
-- axisA: new Vector3(1, 0, 0),
-- axisB: new Vector3(1, 0, 0),
-- collision: desc.collision ?? true,
-- },
-- [
-- {
-- axis: PhysicsConstraintAxis.LINEAR_DISTANCE,
-- minLimit: desc.restLength,
-- maxLimit: desc.restLength,
-- stiffness: desc.stiffness,
-- damping: desc.damping,
-- },
-- ],
-- this.scene,
-- )
-- case 'hinge':
-- case 'slider': {
-- const frames = jointFrames(desc.axisA, desc.axisB, recA, recB)
-- const limits: { axis: PhysicsConstraintAxis; minLimit: number; maxLimit: number }[] = []
-- const lock = (axis: PhysicsConstraintAxis) =>
-- limits.push({ axis, minLimit: 0, maxLimit: 0 })
-- if (desc.type === 'hinge') {
-- lock(PhysicsConstraintAxis.LINEAR_X)
-- lock(PhysicsConstraintAxis.LINEAR_Y)
-- lock(PhysicsConstraintAxis.LINEAR_Z)
-- lock(PhysicsConstraintAxis.ANGULAR_Y)
-- lock(PhysicsConstraintAxis.ANGULAR_Z)
-- if (desc.limits) {
-- limits.push({
-- axis: PhysicsConstraintAxis.ANGULAR_X,
-- minLimit: desc.limits.min,
-- maxLimit: desc.limits.max,
-- })
-- }
-- } else {
-- lock(PhysicsConstraintAxis.LINEAR_Y)
-- lock(PhysicsConstraintAxis.LINEAR_Z)
-- lock(PhysicsConstraintAxis.ANGULAR_X)
-- lock(PhysicsConstraintAxis.ANGULAR_Y)
-- lock(PhysicsConstraintAxis.ANGULAR_Z)
-- if (desc.limits) {
-- limits.push({
-- axis: PhysicsConstraintAxis.LINEAR_X,
-- minLimit: desc.limits.min,
-- maxLimit: desc.limits.max,
-- })
-- }
-- }
-- return new Physics6DoFConstraint(
-- {
-- pivotA: toBjs(desc.anchorA),
-- pivotB: toBjs(desc.anchorB),
-- axisA: frames.axisA,
-- axisB: frames.axisB,
-- perpAxisA: frames.perpA,
-- perpAxisB: frames.perpB,
-- collision: false,
-- },
-- limits,
-- this.scene,
-- )
-- }
-- }
-- }
--
-- setConstraintMotor(id: ConstraintId, motor: ConstraintMotor): void {
-- const rec = this.constraints.get(id)
-- if (!rec || (rec.type !== 'hinge' && rec.type !== 'slider')) return
-- const axis =
-- rec.type === 'hinge' ? PhysicsConstraintAxis.ANGULAR_X : PhysicsConstraintAxis.LINEAR_X
-- const c = rec.constraint as Physics6DoFConstraint
-- c.setAxisMotorType(axis, PhysicsConstraintMotorType.VELOCITY)
-- c.setAxisMotorTarget(axis, motor.targetVelocity)
-- c.setAxisMotorMaxForce(axis, motor.maxForce)
-- }
--
-- removeConstraint(id: ConstraintId): void {
-- const rec = this.constraints.get(id)
-- if (!rec) return
-- rec.constraint.dispose()
-- this.constraints.delete(id)
-- }
--
-- applyForce(id: BodyId, force: Vec3): void {
-- const rec = this.mustGet(id)
-- if (rec.motion !== 'dynamic') return
-- this._v1.set(force.x, force.y, force.z)
-- this._v2.copyFrom(rec.node.position)
-- rec.body.applyForce(this._v1, this._v2)
-- }
--
-- raycast(from: Vec3, to: Vec3, collidesWith: number): RayHit | null {
-- this._v1.set(from.x, from.y, from.z)
-- this._v2.set(to.x, to.y, to.z)
-- this.plugin.raycast(this._v1, this._v2, this._rayResult, {
-- membership: 0xffffffff,
-- collideWith: collidesWith,
-- })
-- const r = this._rayResult
-- if (!r.hasHit || !r.body) return null
-- const bodyId = this.bodyIds.get(r.body)
-- if (bodyId === undefined) return null
-- return {
-- bodyId,
-- point: { x: r.hitPointWorld.x, y: r.hitPointWorld.y, z: r.hitPointWorld.z },
-- normal: { x: r.hitNormalWorld.x, y: r.hitNormalWorld.y, z: r.hitNormalWorld.z },
-- fraction: r.hitDistance / Math.max(this._v2.subtract(this._v1).length(), 1e-9),
-- }
-- }
--
-- sweepCapsule(
-- from: Vec3,
-- to: Vec3,
-- radius: number,
-- height: number,
-- collidesWith: number,
-- exclude?: BodyId,
-- ): SweepHit | null {
-- const shape = this.getSweepShape(radius, height, collidesWith)
-- this._v1.set(from.x, from.y, from.z)
-- this._v2.set(to.x, to.y, to.z)
-- this._q1.set(0, 0, 0, 1)
-- // ignoreBody lets a player's movement sweep collide with the Player
-- // layer without hitting their own kinematic body.
-- const ignoreBody = exclude === undefined ? undefined : this.bodies.get(exclude)?.body
-- this.plugin.shapeCast(
-- {
-- shape,
-- rotation: this._q1,
-- startPosition: this._v1,
-- endPosition: this._v2,
-- shouldHitTriggers: false,
-- ...(ignoreBody ? { ignoreBody } : {}),
-- },
-- this._castInput,
-- this._castHit,
-- )
-- if (!this._castHit.hasHit) return null
-- const n = this._castHit.hitNormal
-- const p = this._castHit.hitPoint
-- return {
-- fraction: this._castHit.hitFraction,
-- normal: { x: n.x, y: n.y, z: n.z },
-- point: { x: p.x, y: p.y, z: p.z },
-- }
-- }
--
-- dispose(): void {
-- for (const id of [...this.constraints.keys()]) this.removeConstraint(id)
-- for (const id of [...this.bodies.keys()]) this.removeBody(id)
-- for (const shape of this.sweepShapes.values()) shape.dispose()
-- this.sweepShapes.clear()
-- if (this.ownsScene) {
-- const engine = this.scene.getEngine()
-- this.scene.dispose()
-- engine.dispose()
-- }
-- }
--
-- private mustGet(id: BodyId): BodyRecord {
-- const rec = this.bodies.get(id)
-- if (!rec) throw new Error(`unknown physics body ${id}`)
-- return rec
-- }
--
-- private makeShape(desc: ShapeDesc): PhysicsShape {
-- switch (desc.type) {
-- case 'box':
-- return new PhysicsShapeBox(
-- Vector3.Zero(),
-- Quaternion.Identity(),
-- new Vector3(desc.size[0], desc.size[1], desc.size[2]),
-- this.scene,
-- )
-- case 'cylinder':
-- return new PhysicsShapeCylinder(
-- new Vector3(0, -desc.height / 2, 0),
-- new Vector3(0, desc.height / 2, 0),
-- desc.radius,
-- this.scene,
-- )
-- case 'sphere':
-- return new PhysicsShapeSphere(Vector3.Zero(), desc.radius, this.scene)
-- case 'capsule': {
-- const half = Math.max(desc.height / 2 - desc.radius, 0.01)
-- return new PhysicsShapeCapsule(
-- new Vector3(0, -half, 0),
-- new Vector3(0, half, 0),
-- desc.radius,
-- this.scene,
-- )
-- }
-- case 'trimesh': {
-- // Build a transient mesh purely as the geometry source for the
-- // Havok mesh shape; it never renders.
-- const mesh = new Mesh(`trimesh-src-${this.nextId}`, this.scene)
-- const vd = new VertexData()
-- vd.positions = desc.positions
-- vd.indices = desc.indices
-- vd.applyToMesh(mesh)
-- mesh.isVisible = false
-- const shape = new PhysicsShapeMesh(mesh, this.scene)
-- mesh.dispose()
-- return shape
-- }
-- }
-- }
--
-- private getSweepShape(radius: number, height: number, collidesWith: number): PhysicsShapeCapsule {
-- const key = `${radius}:${height}:${collidesWith}`
-- let shape = this.sweepShapes.get(key)
-- if (!shape) {
-- const half = Math.max(height / 2 - radius, 0.01)
-- shape = new PhysicsShapeCapsule(
-- new Vector3(0, -half, 0),
-- new Vector3(0, half, 0),
-- radius,
-- this.scene,
-- )
-- shape.filterMembershipMask = CollisionLayer.Player
-- shape.filterCollideMask = collidesWith
-- this.sweepShapes.set(key, shape)
-- }
-- return shape
-- }
--}
--
--function toBjs(v: Vec3): Vector3 {
-- return new Vector3(v.x, v.y, v.z)
--}
--
--/**
-- * Perpendicular joint frames that make the bodies' CURRENT pose the joint's
-- * zero: perpA is an arbitrary unit vector ⊥ axisA (in A local space) and
-- * perpB is its image through the current world alignment, re-orthogonalized
-- * against axisB. Without this, hinge/slider limits and motor angles would
-- * be measured from an arbitrary datum instead of "where you built it".
-- */
--function jointFrames(
-- axisALocal: Vec3,
-- axisBLocal: Vec3,
-- recA: BodyRecord,
-- recB: BodyRecord,
--): { axisA: Vector3; axisB: Vector3; perpA: Vector3; perpB: Vector3 } {
-- const axisA = toBjs(axisALocal).normalize()
-- const axisB = toBjs(axisBLocal).normalize()
-- // Arbitrary but stable perpendicular to axisA.
-- const seed = Math.abs(axisA.y) < 0.9 ? new Vector3(0, 1, 0) : new Vector3(1, 0, 0)
-- const perpA = Vector3.Cross(axisA, seed).normalize()
-- const rotA = recA.node.rotationQuaternion ?? Quaternion.Identity()
-- const rotB = recB.node.rotationQuaternion ?? Quaternion.Identity()
-- const worldPerpA = perpA.applyRotationQuaternion(rotA)
-- const perpBRaw = worldPerpA.applyRotationQuaternion(rotB.conjugate())
-- // Project out any axisB component (axes may not be perfectly aligned in
-- // world space when the player links two tilted props).
-- const perpB = perpBRaw.subtract(axisB.scale(Vector3.Dot(axisB, perpBRaw)))
-- if (perpB.lengthSquared() < 1e-8) {
-- const seedB = Math.abs(axisB.y) < 0.9 ? new Vector3(0, 1, 0) : new Vector3(1, 0, 0)
-- return { axisA, axisB, perpA, perpB: Vector3.Cross(axisB, seedB).normalize() }
-- }
-- return { axisA, axisB, perpA, perpB: perpB.normalize() }
--}
--
--function setupWorld(scene: Scene, havok: unknown, ownsScene: boolean): HavokWorld {
-- const plugin = new HavokPlugin(false, havok)
-- // Source-engine gravity (sv_gravity 600 ≈ 15.24 m/s²) — real-world 9.81
-- // reads floaty at game scale.
-- scene.enablePhysics(new Vector3(0, -16.5, 0), plugin)
-- // All stepping goes through HavokWorld.step(); never the render loop.
-- scene.physicsEnabled = false
-- return new HavokWorld(scene, plugin, ownsScene)
--}
--
--/** Server-side world: owns a NullEngine scene that is never rendered. */
--export function createHeadlessHavokWorld(havok: unknown): HavokWorld {
-- const engine = new NullEngine()
-- const scene = new Scene(engine)
-- return setupWorld(scene, havok, true)
--}
--
--/** Client-side world sharing the rendered scene (bodies stay invisible; rendering reads game state, not physics nodes). */
--export function createHavokWorldForScene(scene: Scene, havok: unknown): HavokWorld {
-- return setupWorld(scene, havok, false)
--}
ddiff --git a/packages/physics/src/havok/index.ts b/packages/physics/src/havok/index.ts
ddeleted file mode 100644
iindex b4cf5eb..0000000
---- a/packages/physics/src/havok/index.ts
++++ /dev/null
@@@ -1 +0,0 @@
--export { HavokWorld, createHavokWorldForScene, createHeadlessHavokWorld } from './havokWorld.js'
ddiff --git a/packages/physics/src/index.ts b/packages/physics/src/index.ts
iindex fdc6332..bc348ee 100644
---- a/packages/physics/src/index.ts
++++ b/packages/physics/src/index.ts
@@@ -1 +1,2 @@
export * from './types.js'
++export * from './rapier/index.js'
ddiff --git a/packages/physics/src/mock.ts b/packages/physics/src/mock.ts
iindex deec488..09c99c1 100644
---- a/packages/physics/src/mock.ts
++++ b/packages/physics/src/mock.ts
@@@ -649,14 +649,21 @@ function rayPlane(
/**
* Sweeps a vertical capsule (centre segment of half-length `half`, radius
-- * `radius`) against an AABB. The Minkowski sum of a vertical capsule and an
-- * AABB is not an AABB, so this inflates the box by `radius` in x/z and by
-- * `half + radius` in y: conservative at the corners, exact on every face. For
-- * an axis-aligned world — all the mock models — that is the whole story.
++ * `radius`) against an AABB, as a real convex sweep (ADR-0007 M1 decision 9).
*
-- * Havok reports the contact point on the *surface*, not at the capsule centre
-- * (measured: a sweep onto ground at y = 0 returns y = 0 exactly), so the point
-- * is walked back onto the inflated face along −normal.
++ * The capsule touches the box when the distance between the centre SEGMENT and
++ * the box equals `radius`. Because an axis-aligned box is a product of intervals,
++ * that squared distance separates per axis: the horizontal gap from the segment's
++ * axis to the box's x/z rectangle, plus the vertical gap between the capsule's
++ * segment interval and the box's y interval. As a function of the sweep parameter
++ * it is convex, so the first contact is the first root of `distance² = radius²`:
++ * a fixed-iteration ternary search for the minimum, then a bisection back to the
++ * crossing. Both are deterministic to the bit, which a padded-AABB approximation
++ * was not — it rounded the box's edges and corners by up to `radius·(√2−1)`.
++ *
++ * A sweep that *starts* already overlapping the box (distance < radius) has no
++ * first contact to report and returns null, exactly as the analytic-plane path
++ * below does; reporting one would block every upward sweep out of a resting hull.
*/
function sweepCapsuleAabb(
from: Vec3,
@@@ -666,63 +673,68 @@ function sweepCapsuleAabb(
center: Vec3,
boxHalf: Vec3,
): SweepHit | null {
-- const lo = [
-- center.x - boxHalf.x - radius,
-- center.y - boxHalf.y - half - radius,
-- center.z - boxHalf.z - radius,
-- ]
-- const hi = [
-- center.x + boxHalf.x + radius,
-- center.y + boxHalf.y + half + radius,
-- center.z + boxHalf.z + radius,
-- ]
-- const o = [from.x, from.y, from.z]
-- const dd = [d.x, d.y, d.z]
-- let tMin = -Infinity
-- let tMax = Infinity
-- let axis = 0
-- let sign = -1
-- for (let a = 0; a < 3; a++) {
-- if (Math.abs(dd[a]!) < 1e-12) {
-- if (o[a]! < lo[a]! || o[a]! > hi[a]!) return null
-- continue
-- }
-- const inv = 1 / dd[a]!
-- let t1 = (lo[a]! - o[a]!) * inv
-- let t2 = (hi[a]! - o[a]!) * inv
-- let s = -1
-- if (t1 > t2) {
-- const tmp = t1
-- t1 = t2
-- t2 = tmp
-- s = 1
-- }
-- if (t1 > tMin) {
-- tMin = t1
-- axis = a
-- sign = s
-- }
-- if (t2 < tMax) tMax = t2
-- if (tMin > tMax) return null
-- }
-- // Already overlapping at t = 0: the capsule starts inside, which is not a
-- // contact the caller can act on.
-- if (tMin <= 0 || tMin > 1) return null
-- const normal = vec3(0, 0, 0)
-- if (axis === 0) normal.x = sign
-- else if (axis === 1) normal.y = sign
-- else normal.z = sign
-- // Walk the centre back onto the true surface: distance is the capsule's
-- // support along the normal, `radius` on the sides and `half + radius` on
-- // the caps.
-- const support = axis === 1 ? half + radius : radius
-- const centre = vec3(from.x + d.x * tMin, from.y + d.y * tMin, from.z + d.z * tMin)
-- const point = vec3(
-- centre.x - normal.x * support,
-- centre.y - normal.y * support,
-- centre.z - normal.z * support,
-- )
-- return { fraction: tMin, normal, point }
++ const ax = center.x - boxHalf.x
++ const bx = center.x + boxHalf.x
++ const ay = center.y - boxHalf.y
++ const by = center.y + boxHalf.y
++ const az = center.z - boxHalf.z
++ const bz = center.z + boxHalf.z
++
++ /** Squared distance between the swept segment and the box at parameter t. */
++ const distanceSq = (t: number): number => {
++ const x = from.x + d.x * t
++ const y = from.y + d.y * t
++ const z = from.z + d.z * t
++ const dx = x < ax ? ax - x : x > bx ? x - bx : 0
++ const dz = z < az ? az - z : z > bz ? z - bz : 0
++ const top = y + half
++ const bot = y - half
++ const dy = top < ay ? ay - top : bot > by ? bot - by : 0
++ return dx * dx + dy * dy + dz * dz
++ }
++
++ const r2 = radius * radius
++ if (distanceSq(0) < r2) return null
++
++ // Minimum over the segment, by ternary search (convex, so this is exact to the
++ // iteration count; the count is fixed so the result is reproducible).
++ let lo = 0
++ let hi = 1
++ for (let i = 0; i < 80; i++) {
++ const m1 = lo + (hi - lo) / 3
++ const m2 = hi - (hi - lo) / 3
++ if (distanceSq(m1) < distanceSq(m2)) hi = m2
++ else lo = m1
++ }
++ const tMin = (lo + hi) / 2
++ if (distanceSq(tMin) > r2) return null
++
++ // First crossing: bisect [0, tMin] keeping the distance above `radius` on the
++ // left and at or below it on the right.
++ let left = 0
++ let right = tMin
++ for (let i = 0; i < 60; i++) {
++ const mid = (left + right) / 2
++ if (distanceSq(mid) > r2) left = mid
++ else right = mid
++ }
++ const t = right
++
++ const x = from.x + d.x * t
++ const y = from.y + d.y * t
++ const z = from.z + d.z * t
++ // Closest point on the segment: its vertical position within the capsule's own
++ // interval that is nearest the box.
++ const segY = y + half < ay ? y + half : y - half > by ? y - half : Math.min(Math.max(y, ay), by)
++ const px = Math.min(Math.max(x, ax), bx)
++ const py = Math.min(Math.max(segY, ay), by)
++ const pz = Math.min(Math.max(z, az), bz)
++ const nx = x - px
++ const ny = segY - py
++ const nz = z - pz
++ const len = Math.sqrt(nx * nx + ny * ny + nz * nz)
++ const normal = len > 1e-12 ? vec3(nx / len, ny / len, nz / len) : vec3(0, 1, 0)
++ return { fraction: t, normal, point: vec3(px, py, pz) }
}
/**
ddiff --git a/packages/physics/src/rapier/index.ts b/packages/physics/src/rapier/index.ts
nnew file mode 100644
iindex 0000000..74da760
---- /dev/null
++++ b/packages/physics/src/rapier/index.ts
@@@ -0,0 +1,12 @@
++export {
++ RAPIER_ANGULAR_DAMPING,
++ RAPIER_DEFAULT_MASS_KG,
++ RAPIER_FRICTION,
++ RAPIER_GRAVITY,
++ RAPIER_LINEAR_DAMPING,
++ RAPIER_RESTITUTION,
++ RapierWorld,
++ createRapierWorld,
++ type RapierModule,
++} from './rapierWorld.js'
++export { loadRapier } from './load.js'
ddiff --git a/packages/physics/src/rapier/load.ts b/packages/physics/src/rapier/load.ts
nnew file mode 100644
iindex 0000000..25517a9
---- /dev/null
++++ b/packages/physics/src/rapier/load.ts
@@@ -0,0 +1,16 @@
++import type { RapierModule } from './rapierWorld.js'
++
++/**
++ * Loads and initializes the pinned deterministic Rapier build (ADR-0007
++ * decision 2): `@dimforge/rapier3d-deterministic-compat`, whose wasm is inlined
++ * as base64 so a single entry works in Node and the browser with no asset
++ * plumbing and no loader.
++ *
++ * The import is dynamic on purpose: physics is loaded while the WebSocket
++ * ticket is fetched, so it is never part of the initial JavaScript.
++ */
++export async function loadRapier(): Promise<RapierModule> {
++ const R = (await import('@dimforge/rapier3d-deterministic-compat')) as unknown as RapierModule
++ await R.init()
++ return R
++}
ddiff --git a/packages/physics/src/rapier/rapierWorld.ts b/packages/physics/src/rapier/rapierWorld.ts
nnew file mode 100644
iindex 0000000..f582355
---- /dev/null
++++ b/packages/physics/src/rapier/rapierWorld.ts
@@@ -0,0 +1,478 @@
++import type * as Rapier from '@dimforge/rapier3d-deterministic-compat'
++import type { Quat, Vec3 } from '@openvibe/shared'
++import type {
++ BodyDesc,
++ BodyId,
++ ConstraintDesc,
++ ConstraintId,
++ ConstraintMotor,
++ MotionType,
++ PhysicsWorld,
++ RayHit,
++ ShapeDesc,
++ SweepHit,
++} from '../types.js'
++import { CollisionLayer } from '../types.js'
++
++/**
++ * Rapier implementation of PhysicsWorld (ADR-0007 decision 2).
++ *
++ * The same adapter runs headless on the server and inside the client's rendered
++ * scene — Rapier has no scene or engine indirection, so the two Havok factories
++ * (a NullEngine scene and a scene-sharing world) collapse to one. Stepping is
++ * manual and honours the dt it is given; there is no render-driven coupling.
++ *
++ * The module is injected rather than imported at runtime: the physics package
++ * declares the deterministic `-compat` build, but the seam stays free of it in
++ * type position only, so no wasm or engine code is reached through this file.
++ */
++
++/** The Rapier module surface the adapter drives. */
++export type RapierModule = typeof Rapier
++
++interface BodyRecord {
++ body: Rapier.RigidBody
++ collider: Rapier.Collider
++ motion: MotionType
++}
++
++interface ConstraintRecord {
++ joint: Rapier.ImpulseJoint
++ type: ConstraintDesc['type']
++}
++
++/**
++ * The unit-joint surface (revolute/prismatic) used to drive motors and limits.
++ * Declared structurally so the adapter never has to name a class that differs
++ * between the compat and non-compat builds.
++ */
++interface MotorJoint {
++ setLimits(min: number, max: number): void
++ configureMotorModel(model: number): void
++ setMotorMaxForce(maxForce: number): void
++ configureMotorVelocity(targetVelocity: number, factor: number): void
++}
++
++/** Grippy, dead-on-impact surfaces — the same material Havok used. */
++export const RAPIER_FRICTION = 0.75
++export const RAPIER_RESTITUTION = 0.05
++export const RAPIER_LINEAR_DAMPING = 0.05
++export const RAPIER_ANGULAR_DAMPING = 0.6
++export const RAPIER_DEFAULT_MASS_KG = 10
++export const RAPIER_GRAVITY = -16.5
++
++const SETTLE_LIN_SQ = 0.05 * 0.05
++const SETTLE_ANG_SQ = 0.15 * 0.15
++
++const IDENTITY: Rapier.Rotation = { x: 0, y: 0, z: 0, w: 1 }
++
++/** Rapier interaction groups: membership in the high 16 bits, filter in the low. */
++function groups(membership: number, filter: number): number {
++ return ((membership & 0xffff) << 16) | (filter & 0xffff)
++}
++
++function motionType(R: RapierModule, motion: MotionType): Rapier.RigidBodyType {
++ switch (motion) {
++ case 'dynamic':
++ return R.RigidBodyType.Dynamic
++ case 'static':
++ return R.RigidBodyType.Fixed
++ case 'kinematic':
++ return R.RigidBodyType.KinematicPositionBased
++ }
++}
++
++function bodyDesc(R: RapierModule, desc: BodyDesc): Rapier.RigidBodyDesc {
++ const rb =
++ desc.motion === 'dynamic'
++ ? R.RigidBodyDesc.dynamic()
++ : desc.motion === 'static'
++ ? R.RigidBodyDesc.fixed()
++ : R.RigidBodyDesc.kinematicPositionBased()
++ rb.setTranslation(desc.pos.x, desc.pos.y, desc.pos.z)
++ if (desc.rot) rb.setRotation(desc.rot)
++ if (desc.motion === 'dynamic') {
++ rb.setLinearDamping(RAPIER_LINEAR_DAMPING)
++ rb.setAngularDamping(RAPIER_ANGULAR_DAMPING)
++ }
++ return rb
++}
++
++function colliderDesc(R: RapierModule, shape: ShapeDesc): Rapier.ColliderDesc {
++ switch (shape.type) {
++ case 'box':
++ return R.ColliderDesc.cuboid(shape.size[0] / 2, shape.size[1] / 2, shape.size[2] / 2)
++ case 'cylinder':
++ return R.ColliderDesc.cylinder(shape.height / 2, shape.radius)
++ case 'sphere':
++ return R.ColliderDesc.ball(shape.radius)
++ case 'capsule':
++ // A Rapier capsule's local axis is +Y, as Havok's is; `height` is the
++ // total height including caps, so the cylindrical segment is height/2 -
++ // radius either side of centre.
++ return R.ColliderDesc.capsule(Math.max(shape.height / 2 - shape.radius, 0.01), shape.radius)
++ case 'trimesh':
++ return R.ColliderDesc.trimesh(shape.positions, shape.indices)
++ }
++}
++
++// ── small quaternion helpers for the joint frames ────────────────────────
++
++function qConj(q: Quat): Quat {
++ return { x: -q.x, y: -q.y, z: -q.z, w: q.w }
++}
++
++function qMul(a: Quat, b: Quat): Quat {
++ return {
++ x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
++ y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
++ z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
++ w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z,
++ }
++}
++
++function qRotate(q: Quat, v: Vec3): Vec3 {
++ const ix = q.w * v.x + q.y * v.z - q.z * v.y
++ const iy = q.w * v.y + q.z * v.x - q.x * v.z
++ const iz = q.w * v.z + q.x * v.y - q.y * v.x
++ const iw = -q.x * v.x - q.y * v.y - q.z * v.z
++ return {
++ x: ix * q.w + iw * -q.x + iy * -q.z - iz * -q.y,
++ y: iy * q.w + iw * -q.y + iz * -q.x - ix * -q.z,
++ z: iz * q.w + iw * -q.z + ix * -q.y - iy * -q.x,
++ }
++}
++
++export class RapierWorld implements PhysicsWorld {
++ private nextId: BodyId = 1
++ private nextConstraintId: ConstraintId = 1
++ private readonly bodies = new Map<BodyId, BodyRecord>()
++ private readonly constraints = new Map<ConstraintId, ConstraintRecord>()
++ /** Rigid-body handle → seam id, for raycast hits. */
++ private readonly bodyIds = new Map<number, BodyId>()
++ private readonly sweepShapes = new Map<string, Rapier.Capsule>()
++ private readonly forced = new Set<BodyId>()
++ /**
++ * Rapier builds its broad phase (which every scene query reads) inside
++ * `World.step`; a collider added or moved since the last step is invisible to
++ * `castRay`/`castShape` until then. The seam (and Havok) allowed querying a
++ * freshly built world, so a stale query is served by one zero-dt step, which
++ * updates the broad phase without advancing the simulation.
++ */
++ private broadPhaseStale = false
++ private disposed = false
++
++ private readonly world: Rapier.World
++
++ constructor(private readonly R: RapierModule) {
++ this.world = new R.World({ x: 0, y: RAPIER_GRAVITY, z: 0 })
++ }
++
++ step(dt: number): void {
++ this.world.integrationParameters.dt = dt
++ this.world.step()
++ this.broadPhaseStale = false
++ // Havok applied a force for exactly the step that followed it; Rapier keeps
++ // user forces until they are reset, so clear the ones applied this tick.
++ if (this.forced.size > 0) {
++ for (const id of this.forced) this.bodies.get(id)?.body.resetForces(false)
++ this.forced.clear()
++ }
++ }
++
++ addBody(desc: BodyDesc): BodyId {
++ const id = this.nextId++
++ const body = this.world.createRigidBody(bodyDesc(this.R, desc))
++ const cd = colliderDesc(this.R, desc.shape)
++ cd.setCollisionGroups(groups(desc.layer, desc.collidesWith))
++ cd.setFriction(RAPIER_FRICTION)
++ cd.setRestitution(RAPIER_RESTITUTION)
++ if (desc.motion === 'dynamic') cd.setMass(desc.massKg ?? RAPIER_DEFAULT_MASS_KG)
++ const collider = this.world.createCollider(cd, body)
++
++ this.bodies.set(id, { body, collider, motion: desc.motion })
++ this.bodyIds.set(body.handle, id)
++ this.broadPhaseStale = true
++ return id
++ }
++
++ removeBody(id: BodyId): void {
++ const rec = this.bodies.get(id)
++ if (!rec) return
++ this.bodyIds.delete(rec.body.handle)
++ // Removes the body and its colliders and joints.
++ this.world.removeRigidBody(rec.body)
++ this.bodies.delete(id)
++ this.forced.delete(id)
++ this.broadPhaseStale = true
++ for (const [cid, c] of this.constraints) {
++ if (!c.joint.isValid()) this.constraints.delete(cid)
++ }
++ }
++
++ getTransform(id: BodyId, outPos: Vec3, outRot: Quat): void {
++ const p = this.mustGet(id).body.translation()
++ outPos.x = p.x
++ outPos.y = p.y
++ outPos.z = p.z
++ const q = this.mustGet(id).body.rotation()
++ outRot.x = q.x
++ outRot.y = q.y
++ outRot.z = q.z
++ outRot.w = q.w
++ }
++
++ setTransform(id: BodyId, pos: Vec3, rot?: Quat): void {
++ const rec = this.mustGet(id)
++ this.broadPhaseStale = true
++ if (rec.motion === 'kinematic') {
++ // A kinematic body chases the target with a computed velocity, so it
++ // pushes dynamic bodies convincingly (Havok: setTargetTransform).
++ rec.body.setNextKinematicTranslation({ x: pos.x, y: pos.y, z: pos.z })
++ if (rot) rec.body.setNextKinematicRotation(rot)
++ return
++ }
++ rec.body.setTranslation({ x: pos.x, y: pos.y, z: pos.z }, true)
++ if (rot) rec.body.setRotation(rot, true)
++ rec.body.wakeUp()
++ }
++
++ setMotionType(id: BodyId, motion: MotionType): void {
++ const rec = this.mustGet(id)
++ if (rec.motion === motion) return
++ rec.body.setBodyType(motionType(this.R, motion), true)
++ rec.motion = motion
++ this.broadPhaseStale = true
++ }
++
++ getLinearVelocity(id: BodyId, out: Vec3): void {
++ const v = this.mustGet(id).body.linvel()
++ out.x = v.x
++ out.y = v.y
++ out.z = v.z
++ }
++
++ setLinearVelocity(id: BodyId, v: Vec3): void {
++ this.mustGet(id).body.setLinvel({ x: v.x, y: v.y, z: v.z }, true)
++ }
++
++ getAngularVelocity(id: BodyId, out: Vec3): void {
++ const v = this.mustGet(id).body.angvel()
++ out.x = v.x
++ out.y = v.y
++ out.z = v.z
++ }
++
++ setAngularVelocity(id: BodyId, v: Vec3): void {
++ this.mustGet(id).body.setAngvel({ x: v.x, y: v.y, z: v.z }, true)
++ }
++
++ isSettled(id: BodyId): boolean {
++ const rec = this.mustGet(id)
++ if (rec.motion !== 'dynamic') return true
++ const v = rec.body.linvel()
++ if (v.x * v.x + v.y * v.y + v.z * v.z > SETTLE_LIN_SQ) return false
++ const w = rec.body.angvel()
++ return w.x * w.x + w.y * w.y + w.z * w.z <= SETTLE_ANG_SQ
++ }
++
++ wake(id: BodyId): void {
++ const rec = this.mustGet(id)
++ if (rec.motion !== 'dynamic') return
++ rec.body.wakeUp()
++ }
++
++ addConstraint(desc: ConstraintDesc): ConstraintId {
++ const recA = this.mustGet(desc.bodyA)
++ const recB = this.mustGet(desc.bodyB)
++ const data = this.buildConstraint(desc, recA, recB)
++ const joint = this.world.createImpulseJoint(data, recA.body, recB.body, true)
++ if (desc.type === 'rope' || desc.type === 'spring') {
++ joint.setContactsEnabled(desc.collision ?? true)
++ } else {
++ joint.setContactsEnabled(false)
++ }
++ if (desc.type === 'hinge' || desc.type === 'slider') {
++ if (desc.limits) (joint as unknown as MotorJoint).setLimits(desc.limits.min, desc.limits.max)
++ if (desc.motor) this.applyMotor(joint, desc.motor)
++ }
++ const id = this.nextConstraintId++
++ this.constraints.set(id, { joint, type: desc.type })
++ return id
++ }
++
++ private buildConstraint(
++ desc: ConstraintDesc,
++ recA: BodyRecord,
++ recB: BodyRecord,
++ ): Rapier.JointData {
++ const R = this.R
++ switch (desc.type) {
++ case 'weld': {
++ const pA = recA.body.translation()
++ const pB = recB.body.translation()
++ const rA = recA.body.rotation()
++ const rB = recB.body.rotation()
++ // Preserve the CURRENT relative pose: pivotA = B's origin in A's local
++ // space; frameA = the rotation taking A's basis to B's.
++ const rel = qMul(qConj(rA), rB)
++ const pivot = qRotate(qConj(rA), { x: pB.x - pA.x, y: pB.y - pA.y, z: pB.z - pA.z })
++ return R.JointData.fixed(pivot, rel, { x: 0, y: 0, z: 0 }, IDENTITY)
++ }
++ case 'rope':
++ return R.JointData.rope(desc.length, desc.anchorA, desc.anchorB)
++ case 'spring':
++ return R.JointData.spring(
++ desc.restLength,
++ desc.stiffness,
++ desc.damping,
++ desc.anchorA,
++ desc.anchorB,
++ )
++ case 'hinge':
++ return R.JointData.revolute(desc.anchorA, desc.anchorB, normalize(desc.axisA))
++ case 'slider':
++ return R.JointData.prismatic(desc.anchorA, desc.anchorB, normalize(desc.axisA))
++ }
++ }
++
++ /** Drives a revolute (hinge) or prismatic (slider) joint's motor along its free axis. */
++ private applyMotor(joint: Rapier.ImpulseJoint, motor: ConstraintMotor): void {
++ const j = joint as unknown as MotorJoint
++ j.configureMotorModel(this.R.MotorModel.ForceBased)
++ j.setMotorMaxForce(motor.maxForce)
++ // A velocity target with a strong damping factor, capped by maxForce above.
++ j.configureMotorVelocity(motor.targetVelocity, Math.max(motor.maxForce, 1))
++ }
++
++ setConstraintMotor(id: ConstraintId, motor: ConstraintMotor): void {
++ const rec = this.constraints.get(id)
++ if (!rec || (rec.type !== 'hinge' && rec.type !== 'slider')) return
++ this.applyMotor(rec.joint, motor)
++ }
++
++ removeConstraint(id: ConstraintId): void {
++ const rec = this.constraints.get(id)
++ if (!rec) return
++ this.world.removeImpulseJoint(rec.joint, false)
++ this.constraints.delete(id)
++ }
++
++ applyForce(id: BodyId, force: Vec3): void {
++ const rec = this.mustGet(id)
++ if (rec.motion !== 'dynamic') return
++ rec.body.addForce({ x: force.x, y: force.y, z: force.z }, true)
++ this.forced.add(id)
++ }
++
++ raycast(from: Vec3, to: Vec3, collidesWith: number): RayHit | null {
++ this.ensureQueriesCurrent()
++ const origin = { x: from.x, y: from.y, z: from.z }
++ const dir = { x: to.x - from.x, y: to.y - from.y, z: to.z - from.z }
++ const ray = new this.R.Ray(origin, dir)
++ // The query's own membership is all-on; the filter is the caller's mask, so
++ // a collider is hit exactly when its layer intersects the mask.
++ const hit = this.world.castRayAndGetNormal(
++ ray,
++ 1,
++ true,
++ undefined,
++ groups(0xffff, collidesWith),
++ )
++ if (!hit) return null
++ const parent = hit.collider.parent()
++ if (!parent) return null
++ const bodyId = this.bodyIds.get(parent.handle)
++ if (bodyId === undefined) return null
++ return {
++ bodyId,
++ point: {
++ x: origin.x + dir.x * hit.timeOfImpact,
++ y: origin.y + dir.y * hit.timeOfImpact,
++ z: origin.z + dir.z * hit.timeOfImpact,
++ },
++ normal: { x: hit.normal.x, y: hit.normal.y, z: hit.normal.z },
++ fraction: hit.timeOfImpact,
++ }
++ }
++
++ sweepCapsule(
++ from: Vec3,
++ to: Vec3,
++ radius: number,
++ height: number,
++ collidesWith: number,
++ exclude?: BodyId,
++ ): SweepHit | null {
++ this.ensureQueriesCurrent()
++ const shape = this.getSweepShape(radius, height)
++ // A vertical capsule cast at a constant velocity, capped at one segment.
++ const hit = this.world.castShape(
++ { x: from.x, y: from.y, z: from.z },
++ IDENTITY,
++ { x: to.x - from.x, y: to.y - from.y, z: to.z - from.z },
++ shape,
++ 0,
++ 1,
++ false,
++ undefined,
++ groups(CollisionLayer.Player, collidesWith),
++ undefined,
++ exclude === undefined ? undefined : this.bodies.get(exclude)?.body,
++ )
++ if (!hit) return null
++ return {
++ fraction: hit.time_of_impact,
++ normal: { x: hit.normal1.x, y: hit.normal1.y, z: hit.normal1.z },
++ point: { x: hit.witness1.x, y: hit.witness1.y, z: hit.witness1.z },
++ }
++ }
++
++ dispose(): void {
++ if (this.disposed) return
++ this.disposed = true
++ this.constraints.clear()
++ this.bodies.clear()
++ this.bodyIds.clear()
++ this.sweepShapes.clear()
++ this.forced.clear()
++ this.world.free()
++ }
++
++ private mustGet(id: BodyId): BodyRecord {
++ const rec = this.bodies.get(id)
++ if (!rec) throw new Error(`unknown physics body ${id}`)
++ return rec
++ }
++
++ /** One zero-dt step to rebuild the broad phase when a query precedes a step. */
++ private ensureQueriesCurrent(): void {
++ if (!this.broadPhaseStale) return
++ const dt = this.world.integrationParameters.dt
++ this.world.integrationParameters.dt = 0
++ this.world.step()
++ this.world.integrationParameters.dt = dt
++ this.broadPhaseStale = false
++ }
++
++ private getSweepShape(radius: number, height: number): Rapier.Capsule {
++ const key = `${radius}:${height}`
++ let shape = this.sweepShapes.get(key)
++ if (!shape) {
++ shape = new this.R.Capsule(Math.max(height / 2 - radius, 0.01), radius)
++ this.sweepShapes.set(key, shape)
++ }
++ return shape
++ }
++}
++
++function normalize(v: Vec3): Vec3 {
++ const len = Math.hypot(v.x, v.y, v.z)
++ if (len < 1e-9) return { x: 1, y: 0, z: 0 }
++ return { x: v.x / len, y: v.y / len, z: v.z / len }
++}
++
++/** A fresh Rapier world. No scene, no engine: the same call on server and client. */
++export function createRapierWorld(R: RapierModule): RapierWorld {
++ return new RapierWorld(R)
++}
ddiff --git a/packages/physics/src/types.ts b/packages/physics/src/types.ts
iindex c799e5a..e2aa586 100644
---- a/packages/physics/src/types.ts
++++ b/packages/physics/src/types.ts
@@@ -2,10 +2,10 @@ import type { Quat, Vec3 } from '@openvibe/shared'
/**
* Engine-agnostic physics facade. Gameplay and server code depend on these
-- * types only; the Havok/Babylon implementation lives behind `@openvibe/physics/havok`
-- * and no Havok or Babylon type ever crosses this boundary. That keeps
-- * simulation testable without wasm and leaves room to swap/isolate the
-- * engine (e.g. worker-side physics) later.
++ * types only; the Rapier implementation lives behind `@openvibe/physics/rapier`
++ * and no Rapier type ever crosses this boundary. That keeps simulation
++ * testable without wasm and leaves room to swap/isolate the engine (e.g.
++ * worker-side physics) later.
*/
/** Opaque handle to a body inside a PhysicsWorld instance. Not persistent — never serialize it. */
ddiff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
iindex b25620e..6083be8 100644
---- a/pnpm-lock.yaml
++++ b/pnpm-lock.yaml
@@@ -47,9 +47,6 @@ importers:
'@babylonjs/core':
specifier: ^9.20.0
version: 9.20.0
-- '@babylonjs/havok':
-- specifier: ^1.3.10
-- version: 1.3.14
'@babylonjs/loaders':
specifier: ^9.20.0
version: 9.20.0(@babylonjs/[email protected])([email protected])
@@@ -84,9 +81,9 @@ importers:
apps/server:
dependencies:
-- '@babylonjs/havok':
-- specifier: ^1.3.10
-- version: 1.3.14
++ '@dimforge/rapier3d-deterministic-compat':
++ specifier: 0.21.0
++ version: 0.21.0
'@electric-sql/pglite':
specifier: ^0.5.8
version: 0.5.8
@@@ -121,8 +118,8 @@ importers:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1
version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1(@electric-sql/[email protected])([email protected])([email protected])
openvibe-shared:
-- specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1
-- version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1
++ specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0
++ version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0
pg:
specifier: ^8.23.0
version: 8.23.0
@@@ -182,12 +179,9 @@ importers:
packages/physics:
dependencies:
-- '@babylonjs/core':
-- specifier: ^9.20.0
-- version: 9.20.0
-- '@babylonjs/havok':
-- specifier: ^1.3.10
-- version: 1.3.14
++ '@dimforge/rapier3d-deterministic-compat':
++ specifier: 0.21.0
++ version: 0.21.0
'@openvibe/shared':
specifier: workspace:*
version: link:../shared
@@@ -221,9 +215,6 @@ packages:
'@babylonjs/[email protected]':
resolution: {integrity: sha512-iWM/Re+FnqTEOxkGxvjRjChcPZzydcb0OGtnVk5o0vDQizqwkcpWFlJzfIv1Ij7Li2edJJJDZd6jRzC8QSWuuw==}
-- '@babylonjs/[email protected]':
-- resolution: {integrity: sha512-+j2FDwLtkh5Gb//KizE8Ks3GIB/3vH8Yx9cQsMvZQpsKkH5gn089wIlJ9Yt8dJxbwBmWKGpMTIXxZE7YdOHUnQ==}
--
'@babylonjs/[email protected]':
resolution: {integrity: sha512-zGu6SRvN4uzk5KF0exoSjBZbBcvkEz6iteSvgCn4EP4nQD60W2D4WCtF8m2usZ/yALUOQE5EQON6Tuq1I4LWXg==}
peerDependencies:
@@@ -280,6 +271,9 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
++ '@dimforge/[email protected]':
++ resolution: {integrity: sha512-n7JuU62Obljlgq+OeH2DHhTmRSjRzwuJPl54tS9pFIzVQyvXsO5CH+a5KkIRBtnFWDbg1Zicv9BulG0jfBAopA==}
++
'@electric-sql/[email protected]':
resolution: {integrity: sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==}
@@@ -805,9 +799,6 @@ packages:
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
-- '@types/[email protected]':
-- resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}
--
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@@@ -1343,9 +1334,9 @@ packages:
pg:
optional: true
-- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1:
-- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1}
-- version: 1.30.1
++ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0:
++ resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0}
++ version: 2.2.0
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
@@@ -1814,10 +1805,6 @@ snapshots:
'@babylonjs/[email protected]': {}
-- '@babylonjs/[email protected]':
-- dependencies:
-- '@types/emscripten': 1.41.5
--
dependencies:
'@babylonjs/core': 9.20.0
@@@ -1859,6 +1846,8 @@ snapshots:
'@csstools/[email protected]': {}
++ '@dimforge/[email protected]': {}
++
'@electric-sql/[email protected]': {}
'@esbuild/[email protected]':
@@@ -2170,8 +2159,6 @@ snapshots:
'@types/[email protected]': {}
-- '@types/[email protected]': {}
--
'@types/[email protected]': {}
'@types/[email protected]': {}
@@@ -2786,7 +2773,7 @@ snapshots:
iovalkey: 0.4.0
pg: 8.23.0
-- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1:
++ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0:
dependencies:
jsonwebtoken: 9.0.3