Epic: Rapier physics integration for Arcane clusters (Refs #8, #117, #118, #120, #121)#128
Open
Epic: Rapier physics integration for Arcane clusters (Refs #8, #117, #118, #120, #121)#128
Conversation
…e physics (v1) Introduces a feature-gated Rapier integration as a `ClusterSimulation` wrapper. Drop-in for `run_cluster_loop`: same wire format, same networking, same replication primitives — only the per-tick physics step is new. Architecture: - `RapierClusterSim` IS-A `ClusterSimulation` that HAS-A user `ClusterSimulation`. User logic runs first (intent / game actions), then Rapier integrates pose. - `entity.velocity` is intent-in; `entity.position` is output-only after first- sight spawn (user position writes are silently overwritten by Rapier output). - Despawn driven by `pending_removals` and entity-map disappearance; sync_inputs / sync_outputs filter pending-removal ids to avoid re-spawning bodies the user just asked to remove. - Fixed 1/60 Rapier substeps with accumulator over the variable cluster tick. - v1 default: uniform 0.5-radius sphere collider per entity; per-entity shapes via `user_data` schema deferred. Feature gating: - `rapier3d = "0.32"` declared `optional = true`; `rapier-cluster` feature pulls it in alongside `cluster-ws`. - `arcane_rapier_cluster` binary requires `rapier-cluster`. - Vanilla `cargo build -p arcane-infra` produces zero Rapier in the dep tree. Tests: - 18 unit tests covering lifecycle (spawn/despawn/respawn), multi-entity independence (incl. 500-entity scale), dynamics (velocity passthrough, gravity vs analytic kinematic, velocity-change-mid-sim), user-sim composition (correct context propagation, pending_removals from user code, buff-pattern velocity modulation), and determinism / despawn-respawn round-trip (hand-off scenario). - Vanilla tests unchanged: 65 pass. Feature-on: 83 pass. Clippy silent both modes; doctest in module docs compiles. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…apierClusterSim
Adds the V2 surface so Rapier-backed nodes are usable for real games — without
this, integration is "uniform spheres with invisible collisions" (fine for tech
demo, useless for typical action gameplay).
Public additions:
- `RapierClusterSimulation` trait — sibling of `ClusterSimulation`, lives in
`arcane-infra::rapier_cluster` so it can use Rapier types freely. Receives a
`RapierClusterTickContext` instead of `ClusterTickContext`.
- `RapierClusterTickContext<'a>` — same fields as `ClusterTickContext` plus
`contact_events: &[ContactEvent]` from the previous tick's physics step.
One-tick delay by design: user logic runs first to set intent, physics produces
output for next tick.
- `ContactEvent { entity_a, entity_b, started }` — collisions mapped from
Rapier collider handles back to entity Uuids via a new reverse map.
- `RapierColliderShape::{Ball | Capsule | Cuboid}` — declared per entity via
`RapierClusterSimulation::collider_for`. Default impl returns
`Ball(config.default_body_radius)`. Resolved at first-sight spawn only;
later returns are ignored (despawn-and-respawn to change shape).
- `RapierClusterSim::with_rapier_sim(rapier_sim, config)` constructor for the
new trait. V1 `new` and `with_default_config` constructors preserved.
Internal refactor:
- `RapierClusterSim` now holds a private `Backend { None, Cluster, Rapier }` enum.
`on_tick` dispatches per variant; the Rapier branch builds the extended ctx.
- `RapierState` gains `collider_to_entity: HashMap<ColliderHandle, Uuid>` for
event mapping and `pending_contact_events: Vec<ContactEvent>` populated by a
custom `EventHandler` impl (`CollisionRecorder`) installed during the step.
Spawn loop and shape resolution moved out of `RapierState` into the wrapper
so the active backend can drive `collider_for`.
- Every spawned collider sets `ActiveEvents::COLLISION_EVENTS`.
Tests:
- 6 new V2 tests: contact event surfaces for overlapping spheres; distant
capsules produce no contacts; collider_for honored at first-sight spawn
(verified via direct ColliderSet shape inspection); shape change after
first-sight is ignored AND collider_for is called exactly once per entity;
one-tick-delay semantics for contact events; no duplicate Started for a
persistent overlap.
- All 18 V1 tests pass unchanged (same trait, same constructors, same wire).
Verification: 54 unit tests + 35 integration + 1 doctest pass under
`--features rapier-cluster`. Vanilla 65 tests pass; vanilla `cargo tree`
shows zero `rapier3d` references. Clippy silent both modes.
Closes #118.
Refs #8, #117.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ct tests
Synthesizes findings from the simplify skill (3 parallel review agents:
reuse / quality / efficiency) and a security-review pass, plus an
architectural pass on SemVer stability. Security review found zero
HIGH/MEDIUM vulnerabilities. Adds 14 tests so every module-doc claim is
backed by a test that would fail if the claim broke.
Code polish:
- New `to_rapier(Vec3) -> Vector` and `from_rapier(Vector) -> Vec3` helpers
replacing five sites of triplet `.x as f32, .y as f32, .z as f32` casts.
- `RapierState::set_linvel` now takes `Vec3` instead of three `f64`s.
- Deleted unused `impl Default for RapierColliderShape` (closed a drift door
vs `RapierConfig::default_body_radius`).
- `CollisionRecorder` propagates Mutex poison via `expect()` instead of
silently dropping events on poison — surfaces panics that happen mid-step.
- Per-tick `pending_removals` lookup now uses a `HashSet` (was O(N×M) linear
scan over a slice when entities × removals was non-trivial).
- Extracted `ClusterEnv::from_env()` helper into `cluster_runner`; both
`arcane_cluster` and `arcane_rapier_cluster` binaries use it (was a
verbatim duplicate of env-parsing across the two binaries).
- Stripped `v1`/`v2` release-stage labels from doc comments per project
policy; trimmed several comments that restated the next line of code.
SemVer stability:
- `#[non_exhaustive]` on `RapierColliderShape`, `ContactEvent`,
`RapierClusterTickContext`, `RapierConfig`. Future additions
(e.g. `Cylinder` shape, `impulse_magnitude` on events, query handles in
the context) won't be breaking changes.
New tests (every one corresponds to a documented contract that was
previously unverified):
T1 stopped_event_surfaces_when_bodies_separate
T2 despawn_during_contact_does_not_surface_stopped_event (pins the
no-Stopped-on-despawn behavior; partners detect via the entity map)
T3 default_path_collider_is_a_ball_with_config_radius (V1 default shape
directly inspected; previously only Cuboid was)
T4 capsule_collider_is_honored_at_first_sight (capsule shape inspected)
T5 multi_substep_in_one_cluster_tick (dt=0.1 → 6 substeps, position ~0.1)
T6 slow_dt_accumulates_until_substep_fires (dt=0.005, fires after ~3-4
ticks, position converges to dt_total*v)
T7 contact_resolution_applies_impulse_to_partner (B gets pushed when A
collides with it — Rapier responds, doesn't just detect)
T8 collider_for_invoked_freshly_on_respawn (despawn-respawn-same-uuid
triggers a fresh shape decision)
T9 rapier_ctx_propagates_game_actions_tick_and_dt (V2 parallel of the V1
context-propagation test)
T10 rapier_user_can_request_removal_via_pending_removals (V2 parallel of
the V1 removal test)
T11 mixed_shape_ball_vs_cuboid_produces_contact (cross-shape collision
now exercised; all prior contact tests paired same-shape)
T12 nondefault_gravity_honored_on_arbitrary_axis (gravity isn't hardcoded
to -Y somewhere)
T13 contact_events_do_not_carry_across_handoff (cluster B's first tick
sees ctx.contact_events == &[], not cluster A's events)
T14 capsule_axis_is_y (segment endpoints at (0, ±half_height, 0))
Verification: 68 lib tests (was 54) + 35 integration + 1 doctest pass under
`--features rapier-cluster`. Vanilla 65 unchanged. Clippy silent both modes.
Vanilla dep tree still has zero `rapier3d`.
Refs #117, #118, #8.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… invariant; clarify body kinds in physics backends doc New canonical doc `docs/architecture/entity-model.md` codifies the unified- entity model decided in the 2026-05-03 architecture session: - Arcane has one persistent-world concept: the Entity. Players, NPCs, projectiles, dropped items, structures, player-built walls — all are entities differentiated by per-entity hooks (body kind, collider, material, collision groups, sensor mode), not by separate types at the platform level. Matches modern engine practice (Unreal AActor, Unity GameObject, Bevy ECS, Godot Node). - Two-axis classification (animate × moving/stationary) is described with industry-standard term cross-references. - Physics body kinds (Dynamic / KinematicPositionBased / KinematicVelocityBased / Fixed) are documented with their per-tick cost and migration semantics. - Affinity-bound vs spatial-bound distinction is called out as a clustering concern (not a physics concern); needs follow-up work in the clustering model so Fixed entities don't migrate by PGP affinity. - Terrain is explicitly NOT entities — it's content loaded by the Arcane runtime based on entity positions. Cross-link to terrain epic #119. Updates to `four-bucket-state-model.md`: - Adds the "every entity has bucket-4 durable state" universal invariant up front. This is what makes recovery / migration work and what unifies ephemeral game objects with structural ones in a single concept. Updates to `physics-backends-and-unreal.md` §6 (entity ↔ body mapping): - Adds body-kind row (per-entity hook, default Dynamic). - Adds explicit terrain-is-not-entities row with cross-link to #119. - Notes Rapier's sleep mechanism + Fixed-body solver-skip preserve the "no entities → no simulation" invariant without needing a separate Structure concept. Refs #117, #118, #119, #120, #121, #122, #8, #33. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…in lines CI's `cargo fmt --check` flagged six sites in rapier_cluster.rs (V2 tests with long-form `entities.insert(id, mk_entry(...))` calls and one chained with_collider closure) plus one site in cluster_runner.rs (long-line Uuid::parse_str with map_err). Pure formatting; no functional changes. All 38 rapier_cluster tests pass; clippy silent both modes. Refs #117, #118, #123. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ic + terrain MapProvider framing Updates entity-model.md with the architectural decisions from the 2026-05-03 sessions on cross-engine support and terrain handling. §7 Terrain — rewritten: - Static / voxel / procedural terrain shapes all supported through one per-engine MapProvider interface. - Game owns storage (object storage / SpacetimeDB voxel chunks / on-disk / procedural / hybrid) and authoring tool (engine editor / voxel editor / generator). Arcane owns the loading interface. - Voxel terrain content lives in SpacetimeDB; static mesh content in object storage; map manifest small in SpacetimeDB. §8 (new) Conceptual contract vs. per-engine API: - User-facing APIs are engine-native per plugin (UE C++, Unity C#, Godot, Rapier Rust). Wire format, manager / replication protocols, durable state schema invariant, and conceptual vocabulary are shared. Physics-property enums (BodyKind, ColliderShape, Material) are NOT promoted to a shared arcane-core; each plugin uses engine-native equivalents. - Reverses an earlier proposal to unify physics value types — that would produce four parallel re-implementations of the same enum across language boundaries with no benefit. §9 (new) Engine plugin pattern: - Engine-named base classes (AArcaneUnrealEntity / ArcaneUnityEntity / ArcaneGodotEntity) extending engine-native types. - Per-engine cluster runtime, MapProvider, in-tick imperative ops. - Wire-format byte-compatibility across all engines via shared protocol. §10 (new) Cross-engine entity migration: - Entities can migrate between cluster tiers running different engines. - Devs write per-engine game logic for each tier they support. - Migration is at cluster-process boundaries; durable state in SpacetimeDB is the lingua franca. - Cross-engine consistency for game rules (damage formulas, drop tables) lives in SpacetimeDB reducers called from every engine plugin. - No in-process engine switching; "the function that runs physics for this engine" is the entire cluster binary written in that engine's language. Refs #117, #118, #119, #122, #123, #124. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…isting channel) + SpacetimeDB for durability Corrects an earlier framing that proposed SpacetimeDB pub/sub for cross-cluster voxel chunk synchronization. The right pattern follows the existing entity-replication mechanism: - Real-time replication between clusters → Redis pub/sub (existing channel). - Durable transactional storage → SpacetimeDB (per-chunk durable row). - State that needs both (voxel chunks, destructible-terrain edits, runtime modifications) → write to SpacetimeDB AND publish on Redis. Voxel chunks and destructible-terrain modifications are conceptually just another kind of cross-cluster game state that's both immediate and durable. Same mechanism Arcane already uses for EntityStateDelta. Updates entity-model.md §7 with the corrected Cross-cluster coordination subsection and the corrected Where-things-live storage table. Memory anchor: project_redis_vs_spacetimedb_split.md. Refs #119, #124. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Codifies the architectural decisions made during the Rapier integration work (#117, #118, PR #123) for posterity. Closes the open ADR item in #8's acceptance criteria for the Rapier track. Decisions documented: - Composition over inheritance — RapierClusterSim IS-A ClusterSimulation that wraps a user ClusterSimulation (or RapierClusterSimulation). No new PhysicsBackend trait introduced. - In-process Rust library — no sidecar process, no FFI. Cargo feature rapier-cluster gates the optional rapier3d dependency. Vanilla builds pull zero rapier3d into the dep tree. - Single Mutex<RapierState> wrapper; user code never sees RigidBodySet directly. Entity-keyed in-tick ops only — no off-spine bodies. - Per-entity hooks called once at first-sight spawn; collider shape / material / body kind / collision groups / sensor are spawn-time decisions, not per-tick. - Velocity in / position out contract. User mutations to entity.position during on_tick are silently overwritten by Rapier's post-step output. - Contact events surface with one-tick delay (intent before output). Despawn-during-contact does NOT surface Stopped to the partner — partners detect via the entity map. - All public types are `#[non_exhaustive]` from day one. Alternatives considered + rejected: - Separate crate `arcane-physics-rapier` with new PhysicsBackend trait (rejected — Cargo feature flag achieves dependency isolation with less ceremony). - Sidecar process running Rapier (rejected — IPC overhead destroys per-tick budget for an in-process library). - Direct &mut RigidBodySet exposure to user code (rejected — off-spine bodies and cross-cluster joints would silently break replication invariants). - Engine-neutral physics types in arcane-core shared across backends (rejected — language barriers force per-plugin re-implementations anyway; documented in entity-model.md §8). Updates physics-backends-and-unreal.md §7 to point at the ADR rather than the earlier "separate crate per backend" framing, which the Rapier work refined. Updates docs/architecture/adr/README.md with an index of ADRs, marking ADR-001 as Accepted and ADR-002 (Unreal Cluster Node) as Pending per #124. Refs #8, #117, #118, #119, #122, #123, #124. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
# Conflicts: # crates/arcane-infra/Cargo.toml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Quick Summary
feat/rapier-cluster. Founder reviews + merges when the epic is complete.#8.RapierClusterSimulationtrait +RapierClusterTickContext), entity-keyed everything, integrates with the existingcluster_runnerwithout changing networking / replication / persist.RapierClusterSim, contact events, per-entity body kind / material / filtering / sensor hooks, in-tick imperative ops (apply impulse / force / torque, raycast, intersection queries, joints).#127. Cross-cluster behavior on top of the primitives this branch ships.Change Type
Impact
on_tickthey can apply forces and impulses, teleport, raycast, run intersection queries, and create joints — the full per-tick surface a local Rapier user has, all entity-keyed (no raw Rapier handles in user code).arcane-infra(a documented architectural pillar). All changes are scoped to the newrapier_clustermodule + a small touch incluster_runnerfor wiring; networking / replication / persist are unchanged. Wrapped in feature flagrapier-cluster(off by default for the existing benchmark binary).Sub-PRs landed on
feat/rapier-cluster#123#117+#118RapierClusterSimwrapper,RapierClusterSimulationtrait, contact events with one-tick delay, per-entity collider shapes (Ball / Capsule / Cuboid). Full review-driven polish + ~40 contract tests.#125#120RapierBodyKind { Dynamic / KinematicPositionBased / KinematicVelocityBased / Fixed },RapierMaterial,RapierCollisionGroups. 8 new tests.#126#121PhysicsHandle. apply_impulse / force / torque, set_translation / linvel / angvel, linvel / angvel reads, wake / sleep, raycast, intersections_with_shape, create_joint / remove_joint. Lock-window restructure for the Rapier-aware path. 11 new tests.Architecture decisions captured (this branch)
Codified in ADR-001 (
docs/architecture/adr/001-rapier-cluster-integration-shape.md) and the supporting docs added on this branch (entity-model.md,physics-backends-and-unreal.mdupdates,four-bucket-state-model.mdupdates):#127).entity_id. No off-spine bodies; no raw Rapier handles in user code.#124.entity-model.md§4–§8 captures the canonical taxonomy (body kinds, subclass-vs-property-value polymorphism, affinity-bound vs spatial-bound binding, MapProvider for terrain).user_databuckets. No new replication channels for this branch.Decisions made (aggregated from sub-PRs)
From #123 (Rapier minimum integration + contact events + colliders)
RapierClusterSimwrapsOption<Arc<dyn ClusterSimulation>>instead of replacing it. Samecluster_runner::run_cluster_looppowers both vanilla and Rapier clusters; networking / replication / neighbor merge / persist are guaranteed identical.positionseeds the body translation. Subsequent user writes are overwritten by Rapier's post-step output. Velocity remains intent-in.on_ticksees the contacts. Same-tick reactivity is a future follow-up.Stoppedevent. When a body is removed, contacts terminate silently; partner detects via the entity-map despawn.From #125 (per-entity body kind / material / filtering / sensor hooks)
SpawnParamsstruct (vs 8-argspawn()+#[allow(too_many_arguments)]). Cleaner site, gives one place to extend for future hooks; clippy-clean without lint suppression.pub const fn new(...)constructors onRapierMaterialandRapierCollisionGroups(vs builder pattern).#[non_exhaustive]blocks struct-literal construction outside the defining crate;newis the simplest external constructor.rapier3d::geometry::Groupfromarcane_infraroot. Constructing collision groups requiresGroup::GROUP_1etc.; re-exporting keeps rapier3d version pinning underarcane-infra's control.RapierBodyKindderivesDefaultwith#[default]onDynamic(vs manual impl). Idiomatic, shorter, satisfies clippy.HookSimtest fixture (vs N narrow ones). 8 tests need heterogeneous per-entity configs + per-(hook, entity) call counting; one fixture covers all of them.From #126 (in-tick imperative ops via PhysicsHandle)
on_tickonly forBackend::Rapier(vs all backends). PlainClusterSimulationhas noPhysicsHandleto give it; no functional reason to change its lock behavior.pending_imperative_linvel: HashSet<Uuid>onRapierState(vs onPhysicsHandle). Symmetric withpending_contact_events; avoids lifetime gymnastics of extracting from PhysicsHandle.set_linvel/apply_impulse, not other ops. The spawn-loop sync only doesset_linvel(entry.velocity)— no other op gets clobbered. Unnecessary tracking would just bloat the set.QueryPipelineconstructed transiently per-query (vs stored onRapierStateas the issue spec wrote). Rapier 0.32 changedQueryPipelineto a borrowed view (QueryPipeline<'a>) — can no longer be a stored field.BroadPhaseBvh::as_query_pipeline(...)builds it cheaply on demand. Functionally equivalent.JointSpeccarries axis as plainVec3(vsUnitVector/Unit<Vector>). Rapier 0.32 takesVectordirectly for joint axes; we.normalize()defensively to handle non-unit input.JointId(ImpulseJointHandle)opaque newtype with private field. Preserves the entity-keyed invariant — user never holds a raw Rapier handle.Copy + Eq + Hashfor ergonomic storage.#[non_exhaustive]onJointSpecandRaycastHitwithpub const fn new(...)on RaycastHit. Same external-construction issue as Rapier physics: per-entity body kind + material + collision filtering + sensor hooks #120's types.Fixedbodies silently no-op (returnfalse) (vs panic, vs let Rapier handle it). Gameplay code shouldn't have to query body kind before applying force.ScriptedSim<F>test fixture parameterized by closure (vs per-test struct). 11 tests need different in-tick behaviors; closure-parameterized fixture drops boilerplate. Per-test structs reserved for tests needing richer state (joint despawn cleanup, Fixed-body sim).Verification
cargo build -p arcane-infraand--features rapier-cluster)arcane-infra::rapier_cluster(40 from feat(arcane-infra): Rapier-backed cluster physics — RapierClusterSim + RapierClusterSimulation #123 + 8 from feat(rapier-cluster): per-entity body kind / material / filtering / sensor hooks (#120) #125 + 11 from feat(rapier-cluster): in-tick imperative physics ops via PhysicsHandle (#121) #126 + earlier wrapper tests)cargo clippy --all-targets -- -D warnings)What this branch does NOT deliver (called out so reviewers don't expect it)
#127(kinematic proxies, imperative-op routing, atomic authority transfer). Required for the demo's most interesting behavior; not in this branch.#119(per-engineMapProvider).#122.#122.Reference
#8— Cluster physics backends (Unreal Chaos + multi-engine path; this branch is the Rapier slice)#117(minimum integration),#118(contact events + colliders),#120(body kind / material / filtering / sensor hooks),#121(in-tick imperative ops)#123,#125,#126#122(Rapier gap inventory) — flips landed rows from 🚧 to ✅ once this merges#119— Terrain (per-engineMapProvider)#124— Unreal Cluster Node (Chaos parallel)#127— Cross-cluster physics interaction (newly filed; depends on this as foundation)brainy-bots/arcane-demos#6— clustering visualization demo. Two of its three blocker dependencies are in this branch.docs/architecture/adr/001-rapier-cluster-integration-shape.mddocs/architecture/entity-model.mddocs/architecture/four-bucket-state-model.md(additions)docs/architecture/physics-backends-and-unreal.md(additions)