Physics Backends

3D physics in SimVX runs behind a backend seam: every PhysicsRoot builds an isolated PhysicsWorld, and which implementation it uses is chosen by a single selection rule. Two backends ship today:

Backend

Package

Notes

BuiltinPhysics (3D)

simvx-core (always present)

Pure-Python dependency-free reference solver. No dependency, runs everywhere including the browser. See What the built-in 3D solver is good for.

BuiltinPhysics2D

simvx-core (always present)

Pure-Python 2D solver, a tier above its 3D sibling: real moment of inertia, rotation-honouring circle-box, poly-poly SAT with edge clipping. It refuses no shape pair and the 3D honesty list below does not apply to it.

JoltPhysics

simvx-physics-jolt (opt-in)

Native Jolt (cffi on desktop, JoltPhysics.js WASM on web). 3D only. Full rigid-body solver, oriented-box collision, high body counts.

PymunkPhysics2D

pymunk (installed means used)

Native Chipmunk2D. 2D only.

Installing pymunk makes it the 2D default. Jolt is explicit opt-in: installing simvx-physics-jolt makes it available by name but does not change the 3D default, because a 3D solver swap changes gameplay outcomes.

What the seam promises

Three promises, and they are what makes swapping a backend a decision about performance and tier rather than a rewrite:

  • Identical contract behaviour. The same call has the same effect and the same observable consequences everywhere. A body created DYNAMIC falls, a filter that fails in one direction rejects the pair, changing a body wakes what it was holding up, a pair reports exactly one ENTER and one EXIT, a sensor reports the bodies that overlap it. Game code never branches on which backend it got in order to make a call. Where a backend cannot deliver one of those clauses the shortfall is written down rather than left silent, and sensing has two. One is queryable: a STATIC body is reported only where SENSOR_DETECTS_STATIC is advertised, which today is every backend, with the one exception recorded under that capability (a Jolt sensor whose collider is a mesh). The other is part of what a sensor overlap is: one sensor inside another is reported per observer, each side firing only where its own mask admits the other, so an asymmetric pair fires on one side alone.

  • Numbers only within a tolerance. Two solvers do not agree on where a crate rests or which step it settles on. Numeric equality between the pure-Python solvers and Jolt is explicitly not promised; the differences big enough to design around are the rest of this page.

  • Anything that genuinely differs is queryable. A backend-dependent feature or payload field is a Capability you can ask world.capabilities() about, never a silent substitute, and every one of them ships a documented way to degrade around it.

Two rules follow, and they constrain the engine as much as they describe it. No gameplay-visible discrete decision may be derived from a quantity whose precision is backend-dependent – that is why a character’s step-up is decided by the walkable-slope test of the surface it lands on, and, when that surface is one a rounded character can only perch on the edge of, by a forward probe asking whether walkable ground continues ahead of the landing. Neither reads how precise the sweep was. And every Capability ships a documented degradation path, which is why CONTACT_IMPULSE, the one a game could otherwise be left with nothing for, publishes its fallback on the event itself.

Both suites that hold the backends to this run per implementation of each ABC: packages/core/tests/test_physics_contract_2d.py for the 2D pair and packages/physics-jolt/tests/test_physics_contract_3d.py for the 3D three, where the web arm drives the real JoltPhysics.js wasm under Node.

Selecting a backend

Precedence (highest first):

  1. An explicit PhysicsRoot(backend=...) on the subtree.

  2. The project physics_backend setting (.simvx/config.json).

  3. An installed native backend that opts into auto-discovery AND serves the dimension being built. pymunk does (installing it moves a 2D game onto Chipmunk2D); Jolt does not, so it is reached only by 1 or 2.

  4. Otherwise: BuiltinPhysics / BuiltinPhysics2D.

Level 3 is asked per dimension, so a backend never wins a dimension it cannot build. A 3D-only backend is not a candidate for a 2D world and vice versa, and neither installation affects the other’s default.

To keep the reference solver on a machine where a 2D native backend is installed, name it: PhysicsRoot2D(backend="builtin"), or "physics_backend": "builtin".

Per-subtree

from simvx.core import PhysicsRoot

# Every body under this root simulates on Jolt; the rest of the scene is unaffected.
root = self.add_child(PhysicsRoot(backend="jolt"))
root.add_child(my_dynamic_body)

A scene can hold several PhysicsRoots with different backends at once: see examples/features/physics/backend_compare.py, which runs Builtin and Jolt side by side.

Project-wide

Create .simvx/config.json at the project root:

{ "general": { "physics_backend": "jolt" } }

Now any body with no explicit PhysicsRoot(backend=...) uses Jolt. If the package is not installed, selection falls back to Builtin with a console warning (it never hard-fails).

What the built-in 3D solver is good for

It is a dependency-free reference solver: always present, running everywhere the engine runs including the browser, and the behavioural parity target the seam’s cross-backend tests are written against. It is adequate for spheres and capsules on flat ground, low-density scenes, prototypes, and anything whose gameplay does not turn on the exact resting pose of a stack.

Exact. Every sphere and capsule pairing; sphere, capsule and box against a static triangle mesh (a box reports its own support, so it rests on a mesh floor at the height it rests on a box floor); cylinder-sphere and cylinder ray queries; convex-hull boolean overlap via GJK.

Approximate, and named where it is computed. Cylinder-box (AABB of the cylinder); cylinder-cylinder and capsule-cylinder (the cylinder is treated as a capsule, so the rims round off); convex-hull penetration depth and normal (EPA-lite); convex-hull raycast (AABB of the point cloud); contact response (one linear impulse at the centre of mass, no inertia tensor, so an off-centre impulse never spins a body). Swept queries substep rather than solving a true time of impact, so a reported distance is a lower bound.

Rotation ignored. Box-vs-box, hull point clouds, and a mesh body’s own orientation.

Refused. Convex hull against a triangle mesh. There is no routine for it and the pair returned no contact at all, so a hull fell through a mesh floor in silence. Install simvx-physics-jolt and name it for that pair. create_body, set_body_shape, set_body_filter, shapecast and overlap raise:

BuiltinPhysics cannot report a correct contact for a ConvexHullShape3D against a
ConcaveMeshShape3D: the pair is refused rather than silently returning no contact.
Install simvx-physics-jolt and select it (PhysicsRoot(backend="jolt") or
physics_backend="jolt"), or give the two bodies non-overlapping collision layers.

Only a pair the layer/mask rule says could actually meet is refused, so separating the two bodies’ layers is a legitimate answer; putting them back together raises again.

What Jolt adds over Builtin

  • A production rigid-body solver (warm-started contacts, stable stacking).

  • Oriented collision shapes: a rotating box collider actually sweeps, so a spinning paddle herds objects.

  • A real inertia tensor, so an off-centre impulse spins what it hits.

  • Convex hull against a triangle mesh, the one pair Builtin refuses.

  • Exact swept queries rather than substepped ones.

  • Throughput for high body counts.

Character behaviour is deliberately NOT on that list. A CharacterBody3D is a BodyMode.KINEMATIC body on every backend, moved by one shared collide-and-slide policy over a per-backend sweep primitive, so dynamic bodies rest on it and two characters block each other identically whichever backend is resolved. (Jolt used to additionally push loose bodies aside with its CharacterVirtual; it no longer does, because a character that pushes on one backend and not another is a parity bug, not a feature. MoveResult.collisions hands the contacts back so game code can apply its own impulse.)

Two backend-specific facts worth knowing:

  • Jolt keeps a kinematic body active. Jolt only searches colliding pairs over active bodies, so a kinematic body must be active for an Area3D to report it. Every pose write re-activates it, which a character does every fixed step. The cost is that each kinematic body is visited by the pair search every step; the residual gap is a kinematic body that is created and then never re-posed, which deactivates after Jolt’s sleep timer and drops out of the search until the next pose write. Sleeping is deliberately left at its default rather than pinned off for every kinematic body, so a scene with no characters pays nothing permanent; a body that must stay searchable while parked asks for it by name with can_sleep=False (measured: still active after 900 idle steps, where the same body deactivates by default).

  • Jolt needs a second cast for an in-contact sweep. With an exact time-of-impact sweep, a mover that starts at zero separation gives the narrowphase no separating axis, so the reported axis is not a surface normal. The backend re-decides that one case with a shrunken-shape cast, which recovers the true face. A substepped backend (Builtin, pymunk) never samples the start of its cast and so cannot report the case at all.

2D is unaffected: Jolt is 3D-only; 2D continues to use Builtin (or the optional pymunk backend).

Installing the desktop backend

# in a clone of the monorepo: ask for it by group (compiles the vendored
# Jolt + joltc locally, and keeps the default groups, so pytest comes too)
uv sync --group native

# outside the workspace, or to install just this package into an existing venv
uv pip install -e packages/physics-jolt --no-build-isolation

Not uv sync --package simvx-physics-jolt: --package re-scopes the project and uv sync is an exact sync, so it evicts everything outside that package’s closure, test tooling included.

The package vendors pinned Jolt + joltc C++ sources and builds them via CMake at install time, so an sdist install always works provided a C++17 compiler and CMake are present (this is the first SimVX package that requires a compiler; the pure-Python default never does). Useful knobs:

  • SIMVX_SKIP_JOLT_BUILD=1: install the Python without compiling the native extension (Jolt then resolves to unavailable; selection falls back to Builtin).

  • SIMVX_REQUIRE_JOLT_NATIVE=1: fail the install loudly if the native build cannot complete, instead of skipping.

A prebuilt Linux wheel may be available; it is built with a conservative SIMD baseline (no AVX2) so it runs on older CPUs, at some performance cost. There are no prebuilt macOS/Windows/musl wheels yet (the project runs no CI), so those platforms install from the sdist and need the toolchain above.

Web export

simvx export web automatically bundles the JoltPhysics.js (WASM) runtime when the exported game opts into Jolt (it scans the sources for a backend="jolt" / physics_backend selection). The same explicit-opt-in rule applies; the web default stays BuiltinPhysics (pure Python under Pyodide).

uv run simvx export web examples/demos/waterfall.py -o waterfall.html

Web caveats:

  • Jolt and Pyodide live in separate WASM heaps, so transforms are copied across the boundary once per frame, so practical body counts are lower than desktop (a few thousand, not tens of thousands).

  • It is a separate backend that mirrors only the PhysicsWorld API, not the desktop build.

  • No determinism on web, and no measured contact impulse: see the capability table below, which both backends report through the same gate.

examples/features/physics/jolt_web.py is a minimal web Jolt scene; waterfall.py and physics_backend_compare.py also export to web.

Body scale is parity, not a capability

Every backend honours a per-body scale, and they agree on which scales a given collider can express (see Scale). Builtin and pymunk resize their own shape parameters; both Jolt backends wrap the shared shape in Jolt’s own ScaledShape. A 4x-scaled crate rests at the same height on all four, a 4x-scaled character stops against the scaled collider on all four (to each backend’s own sweep resolution), and a collider that cannot carry a non-uniform scale raises the same error on all four rather than one backend approximating where another refuses.

Capability differences

Every backend implements the same rigid-body methods with the same behaviour, so a node never branches on which backend it got in order to make a call. The few things that genuinely differ are advertised through one gate, world.capabilities(), which returns a frozenset[Capability]:

Capability

Builtin 3D / 2D

pymunk

Jolt desktop

Jolt web

Degradation path where absent

CONTACT_IMPULSE

yes

yes

no

no

read impulse_estimate, published by every backend

CONTINUOUS

yes

no

yes

yes

cap the per-step motion, or sweep it yourself

SLEEP

yes

no

yes

yes

nothing sleeps; every body behaves as an awake one

SENSOR_DETECTS_STATIC

yes

yes

yes*

yes*

make the body KINEMATIC, or poll world.overlap()

DETERMINISTIC

no

no

no

no

replicate state rather than input

VEHICLES

no

no

no

no

wheels as raycasts plus apply_force

SOFT_BODY

no

no

no

no

a lattice of bodies and springs

* On both Jolt lanes, with the single exception of a sensor whose collider is a MESH: see below.

Advertisement runs both ways: some capabilities only a native backend could offer, and CONTACT_IMPULSE is one the pure-Python solver honours while the optional native backend does not. Jolt desktop and Jolt web both report ContactEvent.impulse is None because neither joltc nor JoltPhysics.js exposes the solver’s applied lambda; the builtin solvers and pymunk report the impulse they measured. None is never a stand-in for a measured 0.0.

Which is why every contact also carries impulse_estimate, on every backend and whether or not the capability is advertised: the impulse it would take to arrest the approach, computed from the pair’s masses and the difference of the two bodies’ linear velocities – not from the event’s own rel_velocity, which is the velocity at the contact point, and the third bullet below is why that distinction is the whole reason the number is portable. It is the same formula on all five implementations, so a game that keys hit sounds, screen shake or damage off it always has a number to key off and one of the same kind everywhere – which the measured impulse cannot give, being a different solver’s number each time, or none at all. Measured on a 1 kg box landing on static ground, the four locally runnable backends’ estimates agree to 0.05%, and each backend that measures its own applied impulse reports one within 3% of its estimate (the builtin 3D solver’s two are the same number).

It is deliberately not a measurement, and the three places it differs from one are worth knowing:

  • It ignores restitution, so a bouncy pair really exchanges up to (1 + e) times this.

  • It is 0.0 for a pair that is not approaching, which includes a body settling onto ground that is itself falling, and a pile pressing down at rest. Impacts are what it is for; a running measure of load is not.

  • It ignores spin, and that is deliberate. The obvious way to compute it would be from the rel_velocity published on the same event, but that value is measured at the contact point, and where in the contact patch the point sits is each narrow phase’s own choice: the builtin 2D solver takes the middle of its clipped manifold, Chipmunk takes one end of it, Jolt takes a manifold corner. For a round contact those are the same place, but a crate lands on a whole face, and the spin about half a crate’s difference goes straight into the approach. Computing the estimate from the two centre velocities instead removes the choice from the answer: a 1 kg crate meeting the ground at 2 m/s reports 1.999, 2.000 and 1.999 N*s on the builtin 2D solver, pymunk and the builtin 3D solver, unchanged at 0, 2, 5 and 20 rad/s of spin, and the builtin 3D figure is the same whether the crate is built as a box or as a convex hull of the same eight corners. A 0.5 m wheel spinning at 20 rad/s agrees to 0.04% too.

    Both Jolt lanes feed it the same two centre velocities, which is what makes that true of all five. A Jolt contact listener runs with the bodies locked, so the body interface must not be read from inside it – probed, the step never returns – but JPH_Body’s own getters take no lock, and both adapters read the linear velocities there (JPH_Body_GetLinearVelocity on desktop, Body.GetLinearVelocity in the web shim). The same crate landing at 2 m/s reports 1.999 N*s at 0, 2, 5 and 20 rad/s on the builtin 3D solver and on Jolt desktop alike, as a box and as a convex hull of the same eight corners. Feeding the at-point velocity instead, which is what Jolt did before the linear reader was bound, gave 2.00 / 3.00 / 4.50 / 12.00 for the box and 2.00 / 1.00 / 0.00 / 0.00 for the hull – the last of them reporting no impact for a crate that is certainly hitting the ground.

    So: the estimate is portable everywhere, spinning or not. The spin itself is not lost – it is published at full fidelity, at the contact point, in rel_velocity, which is where a skid or a grind reads it, and which is still each narrow phase’s own choice of point.

sound.volume = min(1.0, contact.impulse_estimate / 10.0)   # portable, every backend
if contact.impulse is not None:
    damage = contact.impulse * 0.5                          # exact where measured

SENSOR_DETECTS_STATIC is the one capability about which bodies reach a trigger at all. An Area3D / Area2D builds its sensor STATIC, and both native libraries build colliding pairs from the MOVING side only, so a static sensor is never searched, which would leave a trigger over level geometry reporting nothing on Jolt or pymunk. All three native adapters therefore hold their sensors KINEMATIC – the caller’s BodyMode is unchanged, and so is everything the body does: a kinematic body takes no gravity, force, impulse or torque, and the two ways a velocity could reach one are both closed against the caller’s mode – a write to a body the caller made STATIC while the library holds it KINEMATIC is kept away from the library rather than integrated, and a velocity taken while the caller had the body KINEMATIC is cleared inside the library when the mode returns to STATIC – so the promotion cannot set a trigger volume moving. What such a write leaves behind is now the same on all five: the value is held on the seam’s side, so body_velocity and the bulk reader hand it back and it becomes the live velocity if the body later turns movable, the way a pure-Python tier that stores a STATIC body’s velocity and declines to integrate it does. A flip is lossless in velocity as it is in mass, so a body frozen while moving reads back the speed it was frozen at and resumes at it – with one exception, measured: pymunk zeroes a body’s velocity as it arrives at caller-STATIC while the library is holding it KINEMATIC (a promoted sensor, and only that case), and reports 0 there where the other four report the speed. Both Jolt adapters keep that store because Jolt has nowhere to put such a write at all: a static body has no MotionProperties. What such a velocity MEANS beyond the readback still differs, because that is the library’s business: on both 2D backends and the builtin 3D solver it is also a surface velocity, carrying whatever rests on the body along (a conveyor belt: a crate resting on the moving surface is carried with it on each of the three, at a speed the solver’s friction model sets), and on Jolt it is inert. That is the whole fix on Chipmunk, whose moving index is queried against the static one. Jolt needs one thing more, because it forms a pair only when one of the two bodies is DYNAMIC (a kinematic body paired with a sensor being the single exception), so both Jolt adapters also set mCollideKinematicVsNonDynamic on every sensor, which lifts that rule for the pairs the sensor is in. Measured on a sensor overlapping one box, all five backends report one ENTER whatever mode the box is in, sleeping bodies included on Jolt, and one for another static sensor too.

It is not free. A sensor runs narrow phase against the static geometry it overlaps, which nothing else in the world pays for, and Jolt’s own header calls the flag CPU-intensive. The cost scales with (sensors x static bodies each one covers) and with nothing else – a world with no sensors is unchanged to within noise. Measured on a mid-range desktop CPU, Jolt desktop with 64 dynamic crates falling over a carpet of 900 static tiles: 0.004 ms/step with no sensors either way, 0.25 -> 0.95 ms/step with four 4 m sensors on the carpet, 0.36 -> 1.75 with eight, 2.5 -> 9.3 with thirty-two. pymunk with 400 static bodies and 100 dynamic: 0.18 ms/step with no sensors either way, 0.18 -> 0.28 with twenty sensors, 0.17 -> 1.07 with a hundred. What a sensor covers is what it pays for, so the same eight sensors over ONE static floor instead of a carpet cost nothing measurable (0.26 -> 0.25 ms/step); a scene with hundreds of trigger volumes over hundreds of static props should budget for it, and can turn a hot trigger into a world.overlap() poll instead.

One documented exception, on the two Jolt lanes: a sensor whose collider is a MESH. Mesh colliders are STATIC-only, on the seam and in Jolt, so such a sensor cannot be held KINEMATIC, drives no pair search of its own, and the flag has nothing to act on. Measured: a mesh sensor straddling one box reports it when it is KINEMATIC or DYNAMIC and never when it is STATIC, and it is blind to sleepers for the same reason. Swapping convex geometry onto it lifts both blindnesses – Jolt allocates a body’s motion properties once, at creation, so the adapter rebuilds the body to do it, which the caller cannot see: same handle, same pose, same everything else. Trigger volumes are boxes and spheres in practice, so this is a corner rather than a wall.

SLEEP covers the whole settling surface: is_sleeping reporting True, wake() / sleep(), the can_sleep body property, and the wake=False opt-out on the live-edit setters (see Sleeping). pymunk is the one backend without it, because Chipmunk2D disables sleeping unless the space is given a finite sleep-time threshold, and asking a body in such a space to sleep aborts the process at the C level rather than raising. Where it is absent nothing ever sleeps, so every body already behaves as an always-awake one: is_sleeping is permanently False, sleep() does nothing, wake=False costs nothing because there is no sleeper to leave undisturbed, and a body flipped to DYNAMIC with it starts moving on the next step rather than being handed over parked. A game that needs a body parked – to stop polling it, or to freeze a finished pile – checks here and keeps it awake instead.

Sleeping and waking are island-atomic wherever SLEEP is advertised: a pile parks as one unit and a change that takes its support away wakes all of it, however deep it is, so nothing a change took support from is left asleep. A pile sharing a floor with another is a separate island, on every backend that sleeps: static geometry joins nothing. Measured on Jolt, a four-high stack reports asleep on the same step for all four boxes, wakes whole when the bottom box is teleported out, and leaves a crate resting eight metres away on the same floor asleep – which is what the builtin solvers now do as well.

What differs is how much more than the island a wake reaches. The builtin solvers wake exactly the contact island, walked through dynamic bodies from the change; Jolt has no contact-set query, so it wakes everything in the volume the body is giving up, which is a superset that can also catch a neighbour merely standing beside the pile. Both honour the contract; a game that branches on is_sleeping may see a body woken on Jolt that the builtin solvers would have left alone.

Asleep also means frozen wherever SLEEP is advertised: a sleeping body reads back the pose it fell asleep at on every step until something wakes it, so a save, a placement check or a golden image taken of a settled scene reads the same numbers each time.

The two world-level sleep thresholds follow the capability: where SLEEP is absent they are stored and read back and change nothing, because nothing sleeps for them to govern.

CONTINUOUS is the same shape of gap: pymunk accepts the continuous flag and integrates discretely anyway (Chipmunk2D has no swept collision), which the engine reports once per body rather than failing, so a fast small body can tunnel through a thin collider there.

Joints and constraints

Every backend anchors a joint the same way, and it is the way every production solver does it: the anchor you pass in world space is captured into each body’s own frame at create, and turned back into world axes by that body’s current rotation on every solver pass. So a pin on a spinning platform orbits with it, a hinge on a post that is itself turning keeps swinging about the post, and a weld carries its whole assembly round. Measured on a bead pinned a metre out from a hub turning at 1 rad/s for two seconds, the four locally runnable backends put it within 1 mm of the arm and within half a milliradian of the two radians the hub turned; the contract suites pin it on all of them, and a hinge axis follows its post to within a degree even when the door is kicked about an axis the post has already turned away from.

What differs is how hard the constraint is:

Builtin 3D / 2D

pymunk

Jolt

Anchor frame

body-local

body-local

body-local

Convergence

a few impulse iterations plus a soft positional bias

Chipmunk’s iterated solver, no position pass

warm-started, production

Angular response

3D stands in inverse_mass for the inertia tensor; 2D carries a real scalar moment

real

real inertia tensor

The convergence row is the one a scene notices. A loaded chain sags on the builtin solvers, and it sags further the more links pull on each other: hang a six-link pendulum of 1 kg beads under 1200 units/s^2 and the builtin 2D solver settles it with its worst link 7.0% long, its tip 312 units below an anchor 300 units of chain away, while pymunk settles the same chain on its rest length to five decimal places, tip at 300.000. Those are the figures for a chain that hangs. A moving chain narrows the gap: examples/features/2d/joints.py kicks this chain and prints the worst link it sees while the chain hangs and while it swings, 22.9% on the builtin against 3.8% on pymunk. pymunk’s links stretch under a swing where they do not under a hang, so read that pair as a swing measurement and not as the sag. A rope that must not stretch wants a native backend, or fewer, heavier links.

Motors, angular limits and breakable joints are absent from the seam itself, so no backend has them: a joint you create is a passive constraint everywhere, and a mechanism that needs driving is driven by apply_torque on the body. The 2D seam adds one constraint the 3D one has no equivalent for, GrooveJoint2D, and the convergence row shows up there as the firmness of its endpoint stop. A 1 kg slider on a groove one unit long either side of its centre, pushed along the rail for ten seconds: pymunk parks it on the endpoint to four decimal places under every load from 0.1 N to 50 N, while the builtin’s soft positional stop lets it past — 14% of the half-length out at 1 N, 69% at 5 N, and nearly seven half-lengths out at 50 N. Kicked rather than pushed, the two overshoot the end by a similar amount and pymunk then pulls the slider back into the groove while the builtin leaves it a little outside. A slider that must not overrun its rail wants pymunk, or a scene that does not lean on the endpoint as a hard stop.

What a groove promises everywhere is the anchor on the rail, and nothing about the slider’s own heading. The rail is stated in the carrier’s frame and the anchor in the slider’s, and both are turned into world axes by their own body’s current rotation every pass, so the constrained point stays on the segment however either body is turning: measured over ten seconds of a carrier at 1 rad/s under a slider spun at 3 rad/s about an anchor a quarter of a unit off its centre, the worst excursion is 0.052 units on the builtin solver and 0.061 on pymunk, against a rail two units long. Where the slider ends up POINTING is a different matter, because a groove leaves its rotation free and what turns it is each solver’s residual torque about the anchor. On that same scenario the two part company: 0.33 rad apart after two seconds and 3.14 rad – half a turn – after ten, with both anchors still on the rail. Read a groove as a slider, not as a bearing; a scene that needs the slider held at an angle welds or pins it. test_physics_contract_2d.py pins the invariant on both backends and records the divergence rather than asserting agreement on it.

Body knobs across the backends

linear_damping, angular_damping and gravity_scale are honoured by all five implementations with the same meaning and the same defaults, so a scene coasts and falls the same way whichever is running. Two implementation notes are worth knowing rather than guessing at:

  • pymunk has no per-body damping or gravity factor at all. A body running the seam defaults is served by Chipmunk’s space-wide damping, which costs nothing; one that deviates gets a per-body velocity integrator applying the seam’s formula exactly. That is a Python call per such body per step and it is not free: measured at about 5.5-6.3 us per deviating body per step (200 falling boxes: 0.21 ms/step on the default path, 1.48 ms/step with all of them deviating; 800 boxes: 3.89 ms against 8.28 ms). Give a handful of bodies their own damping freely; giving a thousand of them theirs is a cost worth knowing about. That backend also runs the seam’s contact_slop rather than Chipmunk’s own pixel-scale default; see the contact_slop paragraph below for what that moves.

  • Jolt maps them straight onto mLinearDamping, mAngularDamping and mGravityFactor. A mesh collider carries no motion properties, so setting damping on one is recorded at the seam and does nothing native – there is no motion on a STATIC-only collider to damp. Jolt’s own integrator damps the velocity after adding the step’s acceleration, where the other backends damp before, so a body under sustained acceleration ends up slightly slower there: one step’s worth of damping is taken off each step’s acceleration, which costs a relative damping * dt. The shortfall does not grow with time, but it does grow with the damping rate. Measured over five seconds of free fall at 60 Hz, against the seam formula: 0.08% at the default 0.05, 0.83% at 0.5, 3.33% at 2.0. With nothing accelerating the body – which is the case damping exists for – the two orders are identical, and that is the case the cross-backend coasting contract covers. If you need a heavily damped body to fall at exactly the same rate on Jolt as on the pure-Python tiers, damp it less and shape the motion with gravity_scale instead.

solver_iterations maps to the builtin velocity loop, Jolt’s velocity steps and Chipmunk’s space.iterations.

position_iterations is the seam’s second convergence dial, and the one place a world knob does something on two backends and nothing on the other three. It is the count of Baumgarte position passes a step makes over the RIGID joints, and both builtin solvers run it; it defaults to 3, which is what they have always run, so adopting it moves no scene. Chipmunk has no position solver at all, so the pymunk lane stores the value and reads it back without running anything – which is also why a chain it hangs sits on its rest length while the builtin solver’s sags. Both Jolt lanes store and read it back too, for a different reason: Jolt keeps its own position-step count in the same settings struct the seam already pushes, and the entry points the two adapters call (the native simvx_jolt_set_world_settings and the shim’s setWorldSettings) do not carry it, so a Jolt world runs the library’s default whatever the knob says. Raise it where a jointed assembly on a builtin solver is too slack; a scene with no joints pays nothing for it either way, since contacts keep their one positional correction per step whatever it is set to.

contact_slop maps to the builtin solvers’ penetration deadband, Jolt’s penetrationSlop and Chipmunk’s collision_slop, so no backend emulates it. It defaults to 0.001 everywhere, which is a metre-scale number and the value the built-in solvers have always used.

Stating it at the seam is what makes the four backends agree, and it displaces both native libraries’ own defaults, so a scene that ran on either before this existed will rest differently now:

Lane

Its own default

Where a landed 1 m crate rests at it

At the seam’s 0.001

Jolt

0.02

0.480

0.499

Chipmunk (pymunk)

0.1

0.477 on a bare drop, and as deep as 0.400 under load

0.499

So every resting Jolt body sits about 19 mm higher than it did, and a loaded Chipmunk one up to ten centimetres higher: a tenth of a unit is nothing at the 50-100 unit sprites Chipmunk was written for and ten centimetres at this engine’s metre scale. Both are measured stable at the seam value – no jitter over ten seconds at rest, three-high stacks all asleep – and a scene that wants a library’s own tolerance back says so in one line (world.contact_slop = 0.02).

The reason this is a knob is that a scene may want a library’s own tolerance back, not that the value should track the scene’s scale. A settled body rests exactly contact_slop inside what it is standing on, at any world scale, so on a backend that sleeps, lower is strictly better and the default is as right in pixels as it is in metres. examples/features/physics/body_knobs.py --test prints the relation directly: crates rest at 0.4990 against a geometric 0.5 at the default and at 0.4030 at 0.1, and the pile sleeps 4/4 at the default.

This page used to claim that a settled four-high stack of 50-unit crates “shows about 0.06 units of residual movement” at the default, and that a pixel-scale game should raise the slop to fix it. Both are measured false on the built-in solver: a settled pile shows no post-settle movement at any slop from 0.001 to 2.0, because the island sleeps, and raising the slop only buys rest error. A lone 20-unit crate at pixel scale sinks 0.0008 / 0.0198 / 0.0998 / 0.4998 / 1.9998 at slop 0.001 / 0.02 / 0.1 / 0.5 / 2.0: the resting depth is the slop, to four decimals, at every value.

Note also that both native backends stop settling deeper once their own speculative-contact limit is reached, so very large values saturate rather than scaling on.

Check what you need at setup rather than mid-collision:

from simvx.core.physics.capability import Capability

if Capability.CONTACT_IMPULSE in root.world.capabilities():
    ...  # key impact damage off contact.impulse
else:
    ...  # fall back to contact.velocity, which every backend measures

See also

  • Physics: the collision detection layer.

  • examples/demos/waterfall.py: flagship Jolt showcase (glass mill, spinning paddle, adaptive flow control, framerate tied to the physics tick).