Physics

SimVX ships a single physics stack that covers rigid bodies, character bodies, sensor zones, joints, and spatial queries, in both 2D and 3D. The API is deliberately symmetric: every 3D class has a 2D sibling with the same shape (PhysicsBody3D / PhysicsBody2D, Area3D / Area2D, and so on), so the patterns below transfer between dimensions by swapping the suffix and the vector type (Vec3 / Vec2).

Two ideas carry the whole model:

  • A PhysicsRoot node owns an isolated simulation world for its sub-branch of the scene tree. Bodies placed under it simulate in that world.

  • A body resolves its world by walking up to the nearest PhysicsRoot ancestor (innermost wins), falling back to a per-scene default world if there is none. So in the simple case you never construct a world by hand: add a PhysicsRoot, put bodies under it, and the fixed-step loop advances them.

Every node that owns a body in a physics world – PhysicsBody3D, CharacterBody3D, Area3D, and their 2D siblings – derives from PhysicsObject3D / PhysicsObject2D. That base is the name to reach for wherever the meaning is “a node with a body in the world”: it is what an Area’s body_entered hands you and what a node-level query resolves a hit to, so those surfaces need neither a union nor an isinstance list.

All names below are importable from simvx.core.

Bodies and modes

PhysicsBody3D (and PhysicsBody2D) is the one rigid-body node. It does not come in Static / Rigid / Kinematic variants; instead it carries a mode property whose value is a BodyMode:

Mode

Behaviour

BodyMode.DYNAMIC

Force-simulated. Responds to gravity, impulses, and contacts. Uses mass. The default.

BodyMode.STATIC

Immovable collider (floors, walls). Simulated as infinite mass, never integrated; mass is retained and takes effect on a flip to DYNAMIC.

BodyMode.KINEMATIC

Code-moved via move_and_collide. Immune to gravity and forces, but pushes dynamic bodies. Simulated as infinite mass, with mass retained for a flip to DYNAMIC.

mode is a normal property and may be flipped at runtime while the body is in the tree (for example to freeze a settled body to STATIC, or wake a ragdoll to DYNAMIC). A flip is lossless: freezing a body to STATIC and waking it later gives back exactly the mass it was created with, on every backend.

mass is in kilograms and must be greater than zero. The two layers enforce that differently on purpose: the node property clamps into (0.001, 100000), so PhysicsBody3D(mass=0.0).mass is 0.001 rather than an error, while a direct call to the backend seam with a non-positive mass raises ValueError. mass is live: editing it on a body that is already in the tree pushes straight into the solver, recomputing the inertia from the body’s current shape and keeping its pose and velocity.

A body needs collision geometry to become active. Supply it either with the shape convenience property or with a CollisionShape3D child; the shape property wins if both are present. A body with neither stays inert (no collider, no contacts) until it re-enters the tree with geometry.

from simvx.core import (
    BodyMode, BoxShape3D, CollisionShape3D, PhysicsBody3D, PhysicsRoot,
    SphereShape3D, Vec3,
)


class Ball(PhysicsBody3D):
    def __init__(self, **kwargs):
        # `shape=` is the single-collider shortcut; equivalent to adding a
        # CollisionShape3D(shape=SphereShape3D(0.5)) child.
        super().__init__(mode=BodyMode.DYNAMIC, mass=2.0, shape=SphereShape3D(0.5), **kwargs)


class Ground(PhysicsBody3D):
    def __init__(self, **kwargs):
        super().__init__(mode=BodyMode.STATIC, **kwargs)
        self.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=Vec3(20, 0.5, 20))))


root = self.add_child(PhysicsRoot(name="World"))          # Y-up, gravity (0, -9.81, 0)
root.add_child(Ground(position=Vec3(0, -0.5, 0)))
root.add_child(Ball(position=Vec3(0, 10, 0)))

Body properties (mode, mass, shape, material, collision_layer, collision_mask, continuous, can_sleep, linear_damping, angular_damping, gravity_scale) all take effect live: writing one on a body that is already in the tree pushes it into the body the solver is already simulating, on every backend. The body is never destroyed and rebuilt for a value change, so it keeps its pose, its velocity and the joints anchored to it, and a sleeping body wakes for the write, because the value it was being solved against has changed under it. The four that can take support away – mode, mass, shape and the collision filter – also wake whatever was resting on the body (see Sleeping); material, continuous, the two damping rates and gravity_scale reach only the body itself, and allowing can_sleep again wakes nothing, since permitting sleep disturbs nobody. Swapping shape changes the geometry as a teleport: an overlap the new collider introduces is pushed apart by the ordinary contact solve over the following steps.

Two limits are worth knowing. A body cannot become shapeless, so clearing the last collider off a live body leaves it with the geometry it has (reported at WARNING). And continuous needs a backend that advertises Capability.CONTINUOUS: the built-in solvers and both Jolt backends do, pymunk does not (Chipmunk2D has no swept collision), so turning it on there is reported and ignored.

These are property writes, and the resources they carry are values: what reaches the simulation is assigning a resource whose values differ. A PhysicsMaterial is frozen, so there is no in-place edit to get wrong, and assigning an equal-valued one is a no-op because the surface did not change:

from dataclasses import replace

body.material = replace(body.material, friction=0.1)

A Shape is immutable by contract for the same reason (it has no setters, and a resource already handed to a body would otherwise have to re-enter every world it is live in); reaching past that to write an attribute is not a write to body.shape, so nothing is pushed. Assign a new shape resource instead.

Parenting a body under a moving node

A body’s pose has one owner, and the mode says who it is. That decides what happens when an ancestor of the body moves – a Node2D group, a Control whose rect is re-laid-out, a parent you drag in the editor viewport.

A STATIC body follows its ancestors. Nothing simulates it, so its pose is authored data, and moving any node above it carries the collider with the sprite. Parent a STATIC body under a moving node when that motion is authoring: a group positioned once at setup, a level chunk assembled under a common origin, a shape nudged in the editor. This is the case the seesaw pivots and fixed obstacles in examples/projects/marble_rally are built on.

A DYNAMIC or KINEMATIC body does not. The simulation owns its pose, and an ancestor’s move would be erased by the next write-back anyway; dragging a character through its handle every time a parent moved would override move_and_slide under it. The ancestor’s move reaches the node’s transform and stops there.

So for a platform that must carry or push other bodies at runtime, do not parent them to it. Make the platform a KINEMATIC body and move that body itself with move_and_collide: a STATIC pose write is a teleport and carries no derived velocity, so riders would be left standing still while the floor slides out from under them, and a body pushed into would be resolved as a penetration rather than transported.

This is a deliberate difference from engines that push the composed transform into every body type regardless of mode, where the same scene gives a simulated body a visible teleport instead.

Collision shapes

Shapes are plain geometry resources, not nodes. Carry one on a body via shape= or on a CollisionShape3D / CollisionShape2D child.

A shape owns its backend collider, the same way a mesh owns its GPU buffer. Share one resource across as many bodies as you like and it costs one collider; let the resource go and the collider goes with it, so a hitbox rebuilt from fresh geometry every frame costs one collider at a time rather than one per distinct size forever. Bodies already built on it keep their geometry, so dropping a resource never pulls the collider out from under something that is simulating.

Two resources of the same size are two colliders, deliberately: that is what makes a resource’s lifetime its own. Share the resource, not the numbers.

3D shapes:

Shape

Arguments

SphereShape3D

radius=0.5

BoxShape3D

half_extents=(0.5, 0.5, 0.5)

CapsuleShape3D

radius=0.5, height=2.0 (Y-axis; height is the total extent including both caps)

CylinderShape3D

radius=0.5, height=2.0 (Y-axis, flat caps)

ConvexHullShape3D

points (>= 4 finite points); also ConvexHullShape3D.from_mesh(vertices)

ConcaveMeshShape3D

vertices, indices (triangle list). Static-only: placing it on a non-STATIC body is an error.

2D shapes:

Shape

Arguments

CircleShape2D

radius=0.5

RectangleShape2D

half_extents=(0.5, 0.5)

CapsuleShape2D

radius=0.5, height=2.0

SegmentShape2D

a line segment

ConvexPolygonShape2D

points

ConcavePolygonShape2D

segments (static-only)

Scale

A body collides at its node’s scale. Scale is a property of the BODY, not of the shape – a shape resource is shared, so it cannot carry one node’s size – and the node pushes its world_scale through with its pose, so a node scaled to 4 has a collider four times the size and rests where it looks like it should.

Not every collider can express every scale, and the ones that cannot say so rather than guessing:

Collider

Scale

box / rectangle, convex hull / polygon, triangle mesh / segment soup

per axis

cylinder

free along Y, one factor across X/Z

sphere, circle, capsule, segment

uniform only

Scaling a sphere collider to (1, 2, 1) raises ValueError naming the fix (scale uniformly, or use a box or hull), because there is no radius that means “squashed” and quietly picking one would put the collider back out of step with what is drawn. Negative components mirror and count as uniform, so the scale.x = -1 sprite flip is fine.

Scale is read off the BODY, so a CollisionShape3D child’s own local scale does not resize the collider: scale the body, not the collider node. (The body has one scale, and the seam gives a body one scale.) CollisionShape3D.collider_scale reports the scale the simulation actually applies – the owning body’s, or the node’s own when it has no body – and is what the editor’s collider overlay and the pick sphere are sized by, so neither can drift from the simulation.

One gap worth knowing: a change to a PARENT’s scale does not reach a child body’s collider until something else writes that child’s pose. The reconcile hook deliberately ignores transform changes arriving from a parent, because dragging a character through its handle would fight move_and_slide.

A CollisionShape3D can opt into CPU mouse-picking with pickable=True, which makes it a target for SceneTree.input_cast / on_picked using its bounding sphere. Picking is off by default and costs nothing when unused.

Use several CollisionShape3D children (rather than the shape property) to build a compound collider from more than one piece of geometry.

Materials

A PhysicsMaterial groups the surface coefficients that drive contact response. It is a value resource: assign the same instance to many bodies for a shared surface, or give each body its own.

from simvx.core import CombineMode, PhysicsMaterial

rubber = PhysicsMaterial(friction=0.9, restitution=0.8)
ice = PhysicsMaterial(friction=0.05, restitution=0.0)

Field

Default

Meaning

friction

0.5

Coulomb friction coefficient (>= 0). 0 is frictionless.

restitution

0.0

Bounciness in [0, 1]. 0 is fully inelastic, so adding a material never starts an existing scene bouncing.

friction_combine

CombineMode.AVERAGE

How this body’s friction combines with the other body’s at a contact.

restitution_combine

CombineMode.AVERAGE

How restitution combines, independently of friction.

The two coefficients combine independently, each via one of four modes: CombineMode.AVERAGE, MIN, MAX, or MULTIPLY. When the two contacting materials request different modes for the same coefficient, the higher-priority mode wins, in the order MAX > MIN > MULTIPLY > AVERAGE, so a deliberately grippy or bouncy surface dominates a neutral neighbour.

A mode is accepted by name wherever it is accepted at all, so a call site and a scene file can both say friction_combine="max" without importing the enum. The canonical spellings are the full words ("average", "minimum", "maximum", "multiply"), which are what a saved scene carries; "min" and "max" are accepted as short forms, and matching ignores case.

grippy = PhysicsMaterial(friction=0.9, friction_combine="max")

Where each knob lives

A physics knob has exactly one home, and which one follows what the knob describes.

Home

Knobs

Because

Material, per contact

friction, restitution, and the two combine modes

They describe a surface, which many bodies share.

Body

mass, linear_damping, angular_damping, gravity_scale, can_sleep

They describe this object’s dynamics.

World

gravity, solver_iterations, position_iterations, sleep_time_threshold, sleep_velocity_threshold, contact_slop

They describe the space everything is in.

Damping and gravity scale

feather = PhysicsBody3D(linear_damping=2.0, angular_damping=2.0)
balloon = PhysicsBody3D(gravity_scale=-0.2)
pickup  = PhysicsBody3D(gravity_scale=0.0)      # hangs where it is put

Damping is the rate at which a body sheds motion with nothing touching it, and it is a body knob because it stands in for the drag of a particular object’s shape and material: a feather and a cannonball want different values in the same air. Both rates default to 0.05 and are applied once per step, to the velocity the body already carries and before that step’s acceleration: v = v * max(0, 1 - damping * dt) + a * dt. 0 coasts forever. Damping the carried velocity rather than the sum is what keeps it out of the way of a resting contact, whose whole job is to cancel the acceleration the body was just given.

Both native lanes damp at that rate and differ only in where their own integrator applies it, which the seam leaves alone rather than replacing. Jolt damps after adding the step’s acceleration, so a body under sustained acceleration runs a relative damping * dt slower there: 0.08% at the default rate and 60 Hz, and 3.3% at a rate of 2. pymunk integrates position before damping the velocity, so a coasting body travels one step’s worth of the speed it has shed further than the formula says: a fixed damping * dt fraction of the coast, 0.083% at the default rate and 0.83% at 0.5. Both are bounded rather than cumulative – Jolt’s vanishes as soon as nothing is accelerating the body, and pymunk’s stays the same fixed fraction of the distance however long the coast runs. See Physics Backends.

That default is load-bearing rather than cosmetic. Before the seam stated it, every backend ran whatever the library under it shipped, and the same scene simulated differently on each: a pushed sphere coasted 3.6 m on the built-in solver, which applied no damping at all, and 78 m on Jolt, which defaults to 0.05. 0.05 is Jolt’s own default, and also PhysX’s and Unity’s for spin; Box2D ships 0 and Godot 0.1.

gravity_scale multiplies the world’s gravity for one body: 1 falls normally, 0 ignores gravity entirely, and a negative value falls upward. It multiplies, so a world with no gravity has none whatever a body says.

World knobs

Reach them through the world a PhysicsRoot owns:

world = self.node_at("World").world
world.gravity = Vec3(0, -3.7, 0)      # Mars
world.solver_iterations = 16          # tighter stacks, proportionally more time
world.position_iterations = 8         # tighter joint chains, same trade
world.sleep_time_threshold = 0.25     # park settled bodies sooner
world.sleep_velocity_threshold = 0.02
world.contact_slop = 0.001            # overlap left uncorrected (metres)

solver_iterations is the impulse-solver iteration count, the one convergence dial every tier has, and defaults to 8. That is the value the built-in solver is tuned at, rather than the 10 Jolt and Chipmunk ship: measured, three-high stacks on the built-in solver never settle at 10 and all settle at 8, while the other two backends are indifferent between the two.

position_iterations is its partner for the joints, and defaults to 3. A sequential-impulse solve has two halves: the velocity loop stops a constraint’s error growing, and the position passes drain the error it leaves behind. Three of them is not much for a rope, and the shortfall is superlinear in the chain’s length rather than a fixed cost per link – measured on the built-in 2D solver at the default, one 1 kg bead hangs 0.7% long, two 1.8%, four 7.9%, six 22.0% and eight 48.0%. Raising the dial drains it: the same six-link chain settles at 21.97% at the default, 8.08% at 8 and 1.81% at 32, the step costing about 1.14x at 8 and 2.1x at 32 of its default-setting time (absolute cost is the machine’s). It drives the joints and not the contacts, whose positional correction is deliberately one pass per step, so a scene with no joints pays nothing for raising it and a settled stack does not move. Both built-in solvers run it; the native lanes store it and read it back without running it, for reasons Physics Backends gives.

Sleep is decided by two world-level thresholds and a per-body boolean, which is the model the recommended backend has: Jolt keeps both thresholds in its world settings struct and exposes only allowSleeping per body. A body that must never sleep says so with can_sleep = False; there is no per-body threshold, because emulating one everywhere would buy nothing that can_sleep does not already give.

contact_slop is the overlap the contact solve tolerates without pushing it back out. It defaults to 0.001, which suits metres, and it is a knob because the engine fixes no world scale: too tight for the scene and a settled body is corrected for ever and never gets to sleep, too loose and it visibly sinks into what it stands on. A 2D game working in pixels – gravity around 980, sprites 50 units across – should raise it to roughly 0.1-0.5, which is the scale Chipmunk’s own default was written for. Both native backends are displaced by the seam stating it, not just Chipmunk; Physics Backends has the numbers.

examples/features/physics/body_knobs.py is a runnable scene of this whole section: three balls falling at three damping rates, a balloon on a negative gravity scale, a pickup on a zero one, one material shared by four crates, and the four world knobs live on keys.

Forces, impulses, and velocity

A body’s live velocity is read and written directly, not through a serialized property:

ball.velocity += Vec3(0, 5, 0)   # linear velocity (Vec3), units/s
ball.spin = Vec3(0, 2, 0)        # angular velocity (Vec3), radians/s (3D)

Dynamic bodies also take forces and impulses. Impulses apply instantly; forces accumulate for the next fixed step and are cleared afterwards, so a sustained force must be re-applied every on_fixed_update:

ball.push(Vec3(0, 20, 0))                       # instantaneous linear impulse
ball.push(Vec3(5, 0, 0), at=ball.world_position + Vec3(0, 1, 0))  # off-centre -> adds spin
ball.spin_up(Vec3(0, 3, 0))                     # instantaneous angular impulse

def on_fixed_update(self, dt):
    ball.add_force(Vec3(0, 0, -12))             # continuous force this step
    ball.add_torque(Vec3(0, 1, 0))              # continuous torque this step

All four are inert on STATIC and KINEMATIC bodies (infinite mass), and a no-op on a body that is not in the tree.

Sleeping

A dynamic body that stops moving falls asleep: it is skipped by integration and by the contact solve until something disturbs it, which is what lets a large resting scene cost almost nothing. is_sleeping reports it. STATIC and KINEMATIC bodies are never asleep – they were never awake – and a body flipped off DYNAMIC stops reporting as asleep at once.

crate.is_sleeping           # True once it has settled
crate.wake()                # simulate it again now
crate.sleep()               # park it now, whatever its sleep timer had reached
crate.can_sleep = False     # never park it: a body a script polls every frame

A pile sleeps as a unit. Each body runs its own timer, but none of them commits until every dynamic body it is touching – and everything those touch in turn – has come to rest as well, because a crate that parked while the crate above it was still moving would be woken again by the next contact and neither would ever settle. What a pile rests on does not join it: STATIC and KINEMATIC bodies are in nobody’s pile, so two stacks sharing a floor settle independently. The corollary is that can_sleep = False reaches further than the body it is set on: a crate that never parks keeps everything touching it awake too, which is the price of a pile that can settle at all.

A sleeping body is frozen, not merely un-integrated: it reads back the pose it fell asleep at on every step until something wakes it, so a save, a placement check or a screenshot of a settled scene reads the same numbers each time.

The settling therefore finishes as the pile parks rather than after it. On the builtin solvers a pile takes its last move into place in the step it falls asleep: whatever overlap the solve still owed is resolved then, in one frame. How far that carries depends on how much was still owed, which grows with the height of the pile and with the gap it was dropped through: a six-high stack of one-metre crates placed already touching moves about a tenth of a crate, the same stack dropped a couple of centimetres into place up to about a fifth, and a ten-high stack further again. A pile that cannot be resolved that far stays awake and tries again over the next few steps. Some overlap cannot be resolved at all – a crate wedged into a gap narrower than it is, one pinned under a ceiling too low for it – and that parks where it stands rather than keeping its pile awake for the life of the level.

Waking is a property of the engine rather than something a game arranges. Every write that changes what the solver reads wakes the body, and the five that can take support away also wake whatever was resting on it, because the change may have removed what was holding it up: shrink the pillar under a sleeping crate and the crate falls. That is what the ordinary wake-on-contact cannot do on its own, since by the time the collision pass runs the support has already gone.

A pile wakes as a unit, exactly as it sleeps as one. The wake is not limited to the bodies the changed one was touching: it carries through the whole pile, so pulling the bottom crate out of a settled stack brings the stack down rather than the one crate that was sitting on it. It stops where the pile does, at STATIC and KINEMATIC bodies, so picking a crate up off a shared floor leaves the rest of the level asleep.

The opposite case – a write that is bookkeeping rather than a disturbance – suppresses the wake with wake=False, which streaming a level in, respawning a pooled object, or an editor writing a value the player cannot feel all want. It lives on the world seam rather than on the node, reached through a body’s public world and handle:

crate.world.set_body_transform(crate.handle, (Vec3(0, 40, 0), Quat()), wake=False)

Suppression is opt-in, because getting it wrong the other way strands a body in mid-air and no later step recovers it. It is available on the five mutators that can take support away (set_body_transform, set_body_mode, set_body_mass, set_body_filter, set_body_shape); impulses and forces take no such flag, as waking is the point of them.

set_body_mode is the one that hands a body to the simulation, so wake=False there means something more than “disturb nothing”: a body flipped to DYNAMIC with the wake suppressed is handed over parked – dynamic and asleep, its velocity zeroed, staying exactly where it stands until something reaches it. That is the whole point for a streamed-in section, whose geometry lands settled instead of paying to fall into place; wake(), a pose write, an impulse or an awake body arriving all resume it. A body with can_sleep = False has no parked state to be handed to, so it is freed awake whichever way wake points.

A node reaches the same state through its own two calls, which is the form to use wherever a node owns the body: writing the mode through the seam would leave the node’s mode Property saying something the body no longer agrees with.

crate.mode = BodyMode.DYNAMIC   # every write wakes, this one included
crate.sleep()                   # ...so park it again, before another step runs
crate.wake()                    # and later, hand it over for real

All of this needs a backend that advertises Capability.SLEEP. pymunk does not: nothing there ever sleeps, so is_sleeping is permanently False, sleep() does nothing, wake=False has no consequence because there is no sleeper to leave undisturbed, and a body freed with it starts moving on the next step because there is nowhere to park it. See Physics Backends.

examples/features/physics/sleeping.py is a runnable scene of the whole surface.

Contacts and signals

Every body emits collided when it begins touching another body and separated when it stops. Both payloads are a Contact (3D) or Contact2D (2D) whose other is the peer body; on collided the point, normal, impulse, impulse_estimate and velocity describe the contact, oriented toward the receiving body.

class Player(PhysicsBody3D):
    def on_ready(self):
        self.collided.connect(self._on_hit)

    def _on_hit(self, contact):
        if contact.other.is_in_group("hazards"):
            self.take_damage()

The engine does the contact bookkeeping, so prefer connecting to these signals over polling for overlaps each frame.

A destroyed body is still named

A body that is DESTROYED while touching something has stopped touching it, so its peer gets separated (and an overlapping area gets body_exited) on the step after it goes, naming the destroyed node itself:

def _on_left(self, contact):
    if contact.other.handle is None:
        self.score += 1      # that one was destroyed
    else:
        self.chase(contact.other)   # that one just moved away

The node in contact.other is real but detached: tree, handle and world are all None, its properties are readable, and every physics call on it is a no-op. The scene tree holds it for exactly the one step it takes to deliver the event and then lets go, so keeping a reference of your own keeps a dead node alive. Read what you need from it and drop it.

This holds however the body left. It is the scene tree’s one-step hold on the destroyed body that keeps the peer nameable, not the deferral, so destroy() and the immediate remove_child() both deliver the closing event. What deferral buys you separately is that destroy() is safe to call from inside the handler that is being dispatched to. See Node System.

contact.impulse is capability-gated

impulse is the normal impulse the solver actually applied at the contact, which makes it the natural input for hit damage and impact sounds. Not every backend can read it: Chipmunk and both builtin solvers expose their applied impulse, but neither Jolt build gives the engine a hook into its solver’s applied lambda, so on Jolt (desktop and web) impulse is None. That is a different fact from 0.0, which means a real contact the solver resolved with no push (a separating velocity). Check support once, at setup, rather than testing for None inside a collision handler:

from simvx.core.physics.capability import Capability

class Level(Node):
    def on_ready(self):
        self._physics = self.add_child(PhysicsRoot(gravity=Vec3(0, -9.81, 0)))
        self._has_impulse = Capability.CONTACT_IMPULSE in self._physics.world.capabilities()

Where it is absent, read contact.impulse_estimate instead. Every backend fills that in on every contact, from the pair’s masses and the speed they are closing at: the impulse it would take to arrest the approach. Being the same formula everywhere it is the portable number, so a game that wants one impact sound on all five backends can read it even where a measurement is available:

def _on_hit(self, contact):
    self.play_thud(volume=min(1.0, contact.impulse_estimate / 10.0))

It is an estimate of an impact, and it is deliberately blind to the solver: it ignores restitution (a bouncy pair really exchanges up to (1 + e) times it) and it is 0.0 for any pair that is not closing, which includes a crate settling onto ground that is itself falling and a pile pressing down at rest. Use impulse where you have it and this everywhere else; use neither as a measure of load.

The closing speed it uses is the difference of the two bodies’ linear velocities, and not the contact.velocity published beside it. That field is the relative velocity at the contact point, spin included, and where in the contact patch each backend puts that point is its own business – which is fine for reading a skid or a grind off it, and no basis at all for a number that is supposed to mean the same thing on five backends. A spinning crate lands on a whole face, and half a crate’s difference in the point is enough to move the apparent approach by more than a factor of two. Taking the two centre velocities instead leaves nothing to disagree about, and a crate reports the same estimate at every spin rate on the builtin solvers and pymunk.

Both Jolt lanes are the exception, because a Jolt contact listener cannot read a body’s linear velocity, so there the estimate is computed from the at-point value after all. It makes no difference to a ball or a wheel; it makes a spinning crate read several times high, or as no impact at all. Key a tumbling body’s effects off its own linear velocity if you ship on Jolt. The backends page has the measurements.

The capability governs the contact-event stream only. move_and_collide is a sweep, not a solver pass, so it returns a SweepHit / SweepHit2D rather than a node-level Contact: it reports what a sweep can measure (the blocker’s handle, the contact point, the separating normal, the guaranteed-clear distance) and has no impulse field to misread.

Porting note: contact.velocity is not Unity’s relativeVelocity

Unity’s Collision.relativeVelocity is the difference of the two rigidbodies’ linear velocities. SimVX’s contact.velocity (ContactEvent.rel_velocity at the world seam) is the relative velocity at the contact point, so a spinning body contributes its surface speed. Both are pre-impact, so the two agree exactly whenever neither body is spinning – which is most ported code, and why the difference is easy to miss. Once a body spins they part: a 0.5 m wheel dropped spinning at 20 rad/s reports 10 m/s across the ground here, where Unity reports none.

If you need Unity’s quantity, note that you cannot subtract it out inside the handler. collided is delivered after the step, so by then the solver has arrested the impact and self.velocity reads zero for the body that just landed. Two things that do work:

  • contact.impulse_estimate is computed from that linear difference. Divided by the pair’s reduced mass it is the closing speed along the normal, which is what most relativeVelocity.magnitude code is really after – for a body of mass m hitting static geometry, impulse_estimate / m.

  • For the full vector, snapshot the body’s own velocity in on_fixed_update before the step that lands it.

The at-point value is the one worth publishing, because deriving it yourself needs the contact point and both angular velocities; the linear one you already have.

Character bodies

CharacterBody3D / CharacterBody2D is a KINEMATIC physics body with a swept movement helper. It lives in the same body table as every other body and carries the same collision_layer / collision_mask, so raycasts, shape queries and areas see it, dynamic bodies collide with it and rest on it, it produces contact events against dynamic bodies, and two characters block each other when their layers and masks mutually opt in. move_and_slide(dt) is the only thing that distinguishes it from any other kinematic body.

Set velocity each frame and call move_and_slide(dt); it moves the body, deflects the velocity along contact surfaces, and updates the grounded state.

from simvx.core import CharacterBody2D, Input, RectangleShape2D, Vec2

class Player(CharacterBody2D):
    def __init__(self, **kwargs):
        super().__init__(shape=RectangleShape2D(half_extents=Vec2(10, 14)), **kwargs)
        self.up_direction = Vec2(0, -1)   # screen space: up is -Y

    def on_update(self, dt):
        vx = (Input.get_strength("right") - Input.get_strength("left")) * 250.0
        vy = 0.0 if self.is_on_floor() and self.velocity.y > 0 else self.velocity.y
        vy += 1400.0 * dt                       # gravity
        if Input.is_action_just_pressed("jump") and self.is_on_floor():
            vy = -450.0                          # a -Y (upward) kick
        self.velocity = Vec2(vx, vy)
        self.move_and_slide(dt)

Configuration is set as properties: slope_limit (degrees, default 45), step_height (default 0), max_slides (default 4), skin_width (default 0.001, the contact clearance the sweep is asked to leave, which also scales the ground probe), plus mass (default 70, kg) and push_factor (default 1), both covered below. These six are read per move, not frozen at enter-tree, so they can be tuned live (a crouch lowering step_height, a sprint raising max_slides), as are collision_layer / collision_mask and the collider itself, like every other physics node’s.

After a move, is_on_floor(), is_on_wall(), is_on_ceiling(), and floor_normal report what was hit; they are valid until the next move_and_slide. up_direction is an instance attribute (default Vec3(0, 1, 0) in 3D) that classifies floor, wall, and ceiling contacts, with slope_limit setting BOTH thresholds: a normal within slope_limit of +up is walkable floor, one within slope_limit of -up is ceiling, and everything between is wall.

velocity is a plain instance attribute, deliberately not the seam-backed PhysicsBody3D.velocity: a character is position-driven, so its stored physics velocity stays zero and the world’s integrator never re-applies the motion move_and_slide has already performed.

Pushing, and carrying

A character does not carry anything, on any backend: a body resting on it is not dragged along when it walks away, because its stored velocity is zero so there is no friction source.

Pushing is two knobs and is ON by default: at push_factor = 1 a character walking into a crate shoves it. Set the factor to 0 and the sweep is blocked by a dynamic body exactly as it is by a static one:

class Player(CharacterBody3D):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.mass = 80.0        # kg; the character's own, read only by the push
        self.push_factor = 0.0  # or set them per instance, or change them mid-game

(Assign on the instance, as here: a bare class attribute would shadow the inherited Property descriptor and bypass its clamping.)

Each blocking contact of a move, except one classified as floor and except an immovable body, receives an impulse of

push_factor * (M * m / (M + m)) * approach_speed

newton-seconds along the normal, where M is the character’s mass, m is the struck body’s mass, and the approach speed is the component of the character’s velocity into that surface. So a sprinting character shoves harder than a walking one, and a heavier character shoves harder than a lighter one.

M * m / (M + m) is the pair’s reduced mass, and times the approach speed it is the impulse of a perfectly inelastic collision – the one that leaves both bodies moving together. That is what makes 1.0 a safe default: one contact changes the struck body’s velocity by push_factor * M / (M + m) * approach_speed, which at 1.0 is at most the character’s own approach speed and never more. Measured on both 3D backends, a 70 kg character walking at 4 m/s hands a 1 kg crate 3.9437 m/s, an equal 70 kg crate 2.0000 m/s and a 1000 kg crate 0.2617 m/s. That is a bound per contact, not a speed limit on the body: keep walking into a light crate, catch it up and hit it again, and the kicks stack. Above 1.0 the impulse stops being physical and becomes a shove-harder dial; the range is 0 to 1000. Both knobs are read per move, like the other four, so a “carrying something heavy” state can lower the factor for as long as it lasts.

The impulse goes through the centre of mass, so a shove slides a crate rather than tumbling it. An immovable body – STATIC or KINEMATIC – has no finite mass to share, so the push skips it and a wall or a parked platform absorbs the walk.

A contact the move classified as floor is the one exemption: standing on a dynamic body imparts nothing to it. A character resting on something presses into it every step at whatever speed gravity has just given it, so pushing floor contacts would turn its weight into a downward impulse; measured with the exemption removed and the character falling at 18 m/s^2, a 70 kg character dropped 8 m onto a 1 kg crate at push_factor = 1 drove it 0.2286 m into the static floor it was resting on and left it 0.0589 m low once everything settled (the built-in solver; 0.0005 m on Jolt, whose solver pushes it straight back out). Walls, ceilings and slopes too steep to walk on are all pushed. If you want a character’s weight to bear on what it stands on, apply it yourself from collisions.

Whatever push_factor is, the engine hands the collisions back rather than guessing what you wanted. After move_and_slide, collisions holds the blocking contacts the move resolved, in slide order, with the contact point for a game that wants its crates to topple, or to push one-way, or to take damage on impact:

def on_fixed_update(self, dt):
    self.velocity = Vec3(self._input_x * 5.0, self.velocity.y - 9.81 * dt, 0.0)
    self.move_and_slide(dt)
    for hit in self.collisions:              # SweepHit: body, point, normal, distance
        self.world.apply_impulse(hit.body, -hit.normal * 3.0, at=hit.point)

Layers in a character-dense scene

Because a character IS a body, a project whose projectiles, pickups and NPCs are all CharacterBody2D puts all of them into the solver’s pair loop and into every other character’s sweep scan. Give each family its own collision_layer, and a collision_mask naming only what it should physically collide with (0 for “nothing: I resolve my own hits”). A sweep pays for every body it MATCHES by layer and almost nothing for one it does not, so this is a performance decision as much as a gameplay one.

Kinematic movement

A KINEMATIC PhysicsBody3D is moved with move_and_collide(velocity, dt=1.0). It sweeps the body’s collider along velocity * dt, stops at the first blocker, syncs the node transform immediately, and returns the SweepHit it hit (or None if it travelled the full distance). Unlike a character it does not slide and does not integrate gravity: one sweep, stop on contact.

A body blocks the sweep only when all of the following hold: it is not the mover; it is not a sensor (a sensor is excluded from collision resolution, so it never blocks a sweep); the pair passes the AND rule; in 2D the one-way filter does not reject it; and the contact opposes the direction of travel, so a surface the mover already rests on does not halt a sideways move. SweepHit.distance is a distance along the motion at which the mover is guaranteed not to penetrate, and the body is placed exactly there: a backend with an exact time-of-impact sweep subtracts the requested clearance, while a backend that substeps reports the last substep it proved clear, which can be exactly 0.0 for a move that begins in contact.

class MovingPlatform(PhysicsBody3D):
    def __init__(self, **kwargs):
        super().__init__(mode=BodyMode.KINEMATIC, shape=BoxShape3D(half_extents=Vec3(2, 0.2, 2)), **kwargs)

    def on_fixed_update(self, dt):
        self.move_and_collide(Vec3(0, math.sin(self.tree.now) * 2.0, 0), dt)

Areas and overlap

Area3D / Area2D is a sensor zone: it participates in the broadphase and detects bodies passing through it, but applies no collision response. It fires body_entered / body_exited (payload: the peer PhysicsObject, so a PhysicsBody or a CharacterBody) and area_entered / area_exited (payload: the peer area), and exposes the live overlap set:

from simvx.core import Area3D, BoxShape3D, CollisionShape3D, Vec3

zone = Area3D(name="Checkpoint", position=Vec3(0, 1, 0))
zone.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=Vec3(2, 2, 2))))
zone.body_entered.connect(self._on_enter)
root.add_child(zone)

# Or poll the maintained set (no per-frame tree scan), with optional filters:
mobs = zone.get_overlapping_bodies(group="mobs")
areas = zone.get_overlapping_areas()

get_overlapping_bodies accepts group= (a scene-tree group name) and type= (a PhysicsBody3D subclass); both are ANDed when supplied. Area detection is one-directional: an area detects a body when area.collision_mask & body.collision_layer is non-zero (the observing area decides). Set monitoring=False to make an area inert.

A SLEEPING body is detected like any other – a pickup trigger moved over a crate that settled minutes ago fires body_entered for it, and detecting it does not wake it – and so is a BodyMode.STATIC one, on every backend: Capability.SENSOR_DETECTS_STATIC is advertised by all five. It costs something, because a trigger then runs narrow phase against the static geometry it covers; physics_backends carries the measured numbers and the one degradation path (poll world.overlap() for a trigger that is hot enough to matter).

There is one exception, on Jolt only: an area whose collider is a ConcaveMeshShape3D. Jolt requires a mesh collider to sit on a static body, and a static body never drives the pair search, so such an area is found only from the other side and therefore sees only what is awake and movable – it misses both the sleeping crate and the static wall. Give the area convex geometry – which is what a trigger volume usually is – and it behaves like every other, whether that geometry is set at creation or swapped in later. See Physics Backends.

GravityArea3D / GravityArea2D extends the sensor into a force field that adds gravity to the dynamic bodies inside it, on top of world gravity. gravity is a uniform acceleration vector; enabling point_gravity adds a pull toward the area centre of magnitude point_strength (default 9.81).

Spatial queries

Ray and shape queries run through node.physics (3D) or node.physics_2d (2D), which returns a query object bound to the node’s resolved world. Results carry the resolved scene node, so you never handle raw physics handles.

hit = self.physics.raycast(origin, direction, distance=60.0, exclude={self})
if hit:
    print(hit.node, hit.point, hit.normal, hit.distance)

for hit in self.physics.raycast_all(origin, direction, distance=60.0):
    hit.node.flash()

contact = self.physics.shapecast(SphereShape3D(0.3), origin, direction, distance=10.0)
bodies = self.physics.overlap(BoxShape3D(half_extents=Vec3(1, 1, 1)), transform)

Method

Returns

raycast(origin, direction, *, distance=inf, mask=0xFFFFFFFF, exclude=None)

nearest RayHit, or None

raycast_all(...)

list[RayHit], ascending by distance

shapecast(shape, origin, direction, *, distance, mask, exclude)

earliest ShapeHit, or None. distance must be finite.

overlap(shape, transform, *, mask, exclude)

list of overlapping body nodes

A RayHit / ShapeHit has .node, .point, .normal, and .distance. exclude takes a set of nodes to ignore (commonly {self}).

Note the resolution rule: node.physics resolves to the nearest PhysicsRoot ancestor of that node, or the tree default world. If you place bodies under a child PhysicsRoot and query from a node above it, you will query the wrong world. Query through the root node itself:

self.world = self.add_child(PhysicsRoot(name="World"))
# bodies added under self.world ...
hits = self.world.physics.raycast_all(origin, direction, distance=60.0)

Layers and masks

Every body and area carries a collision_layer (which layers it occupies) and a collision_mask (which layers it scans), both 32-bit integers defaulting to layer 1. Two bodies collide only when each opts into the other’s layer:

(a.collision_mask & b.collision_layer) and (b.collision_mask & a.collision_layer)

With the defaults everything collides. Set disjoint layers and masks to stop pairs from interacting (for example, giving pendulum beads a mask of 0 so they swing through each other but still hit the ground).

Queries and sensors use a simpler one-sided rule: a raycast / shapecast / overlap considers a body when body.collision_layer & mask is non-zero, and an area detects a body when area.collision_mask & body.collision_layer is non-zero.

For readable named layers, define your own IntFlag and assign combinations; an IntFlag is an int, so it stores, serialises, and bit-tests unchanged:

from enum import KEEP, IntFlag, auto

class Layer(IntFlag, boundary=KEEP):
    WORLD = auto()
    PLAYER = auto()
    ENEMY = auto()

body.collision_layer = Layer.PLAYER
body.collision_mask = Layer.WORLD | Layer.ENEMY

Joints

A joint constrains two bodies. Set body_a and body_b (plain attributes, or constructor keywords) and add the joint to the tree. A joint resolves its bodies once at enter-tree, so add a joint after both of its bodies are in the world; a joint declared before its bodies silently stays inert. Both bodies must live in the same world, or the joint raises ValueError.

3D joints:

Joint

Constrains

FixedJoint3D

Welds the full relative transform: the two bodies move as one.

PinJoint3D(anchor=...)

Pins the bodies at a world-space point; rotation stays free. Falls back to the joint node’s own position if anchor is left at the origin.

HingeJoint3D(anchor=..., axis=...)

A pin plus a single free rotational axis (axis defaults to Vec3(0, 1, 0)).

SpringJoint3D(rest_length=-1, stiffness=100, damping=1)

A soft distance spring between the two centres. rest_length=-1 auto-captures the current separation.

The 2D set mirrors it: FixedJoint2D, PinJoint2D, HingeJoint2D, SpringJoint2D, plus GrooveJoint2D (constrains a body to slide along a groove defined on the other).

from simvx.core import HingeJoint3D, PinJoint3D, Vec3

# A pendulum link pinned to the body above it.
root.add_child(PinJoint3D(body_a=anchor, body_b=bead, anchor=anchor.world_position))

# A door hinged about the vertical axis at the post.
root.add_child(HingeJoint3D(body_a=post, body_b=door, anchor=hinge_pos, axis=Vec3(0, 1, 0)))

Like body properties, a joint reads its parameters once at enter-tree; change a parameter or a body reference by re-entering the joint.

An anchor is a world-space point when you declare it, but it is captured into each body’s own frame at enter-tree, and rebuilt from that body’s current rotation on every solver pass. So the pivot goes where the body goes: 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. That is true on every backend, so a scene behaves the same way whichever one it resolves. Where the pure-Python solvers still fall short of the native ones is convergence and inertia, not anchoring: see Physics Backends.

Worlds and roots

PhysicsRoot (3D) and PhysicsRoot2D (2D) each own an isolated world for their sub-branch. Nesting is allowed, and the innermost root wins for the bodies under it, so a scene can run several independent worlds at once (different gravity, independent stepping).

from simvx.core import PhysicsRoot, PhysicsRoot2D, Vec2, Vec3

space = self.add_child(PhysicsRoot(name="Space", gravity=Vec3(0, 0, 0)))
world = self.add_child(PhysicsRoot(name="World", gravity=Vec3(0, -18, 0)))
ui = self.add_child(PhysicsRoot2D(name="UI", gravity=Vec2(0, 0)))

PhysicsRoot defaults to Y-up gravity Vec3(0, -9.81, 0). 2D worlds are coordinate-agnostic: many 2D examples work in screen pixels with +Y pointing down, so gravity is a positive-Y Vec2. A body with no PhysicsRoot ancestor resolves to a per-scene default world, which is fine for a single-world scene.

The world objects themselves (PhysicsWorld, Physics2DWorld) are a backend seam you rarely touch directly; PhysicsRoot builds and steps one for you. Set gravity per root via the gravity argument.

Fixed timestep

Physics advances on a fixed-step clock, so drive it from on_fixed_update(dt) (there is no separate physics tick to override). Node on_fixed_update hooks run first each step, then every world is advanced once. Set the rate with the application’s physics_fps:

from simvx.graphics import App

App(title="Physics", width=1280, height=720, physics_fps=60).run(GameScene())

Backends and accuracy

The default backend is BuiltinPhysics, a pure-Python solver that runs everywhere including the browser with no extra dependency. It is a capable rigid-body simulator, but some cases are approximate: it has no inertia tensor (off-centre and elongated bodies rotate more freely than a full solver would), and it ignores collider rotation for boxes and hulls in the narrowphase and sweeps. Character behaviour is NOT one of the approximations: the movement policy is one shared implementation over a per-backend sweep primitive, so it is the same on every backend.

Builtin does not model rolling

The most visible consequence of having no inertia tensor is that the Builtin solver does not roll anything. Its contact friction is a linear tangential impulse at the centre of mass, with no contact-point lever arm, so a contact can neither start a spin nor slow one down. A ball pushed along the floor therefore slides and is braked by friction, rather than rolling, and any spin it already has is never worn away.

The difference is large enough to design around. push(at=...) diverges for the same reason: the off-centre lever arm is scaled by inverse mass as a stand-in for inverse inertia, so the resulting spin is far smaller than a real solver’s.

Boxes and general props are much closer, because sliding is exactly what Builtin does model. If rolling props are central to your game, choose Jolt for those scenes.

This is a solver-tier difference, and it is the only one left in how far a pushed body travels. Damping and gravity scale used to add a second one on top of it, because neither was stated at the seam; both are now, so a body with nothing touching it coasts the same distance on every backend. Measured over thirty seconds at the default damping: the two built-in tiers and Jolt agree to four decimal places (155.2724 m each), and pymunk comes in 0.083% further (155.4019 m) because Chipmunk integrates position one step behind. That lag is worth about that much on any moving body and is not a damping difference.

Once the body is touching something, friction enters and the tier difference above is what you are left with, so put a number on it before choosing. A 10 m/s push, twenty seconds, Builtin against Jolt:

Body

friction 0.02

friction 0.05

friction 0.1

Sliding box

0.04%

0.13%

0.29%

Rolling sphere

6.6%

36%

59%

A box agrees closely at any friction, because sliding is what Builtin models. A sphere does not, and the gap grows with friction, which is this section’s subject rather than a knob: the more grip there is, the more a full solver turns sliding into rolling and stops paying friction for it, while Builtin keeps sliding and keeps being braked. Do not read a single divergence figure as a property of the backend pair – it is a property of the scene’s friction.

For oriented collision, stable stacking, and high body counts, the optional native Jolt backend is a per-root, opt-in swap that mirrors this exact API. See Physics Backends for selecting and installing it.

See also

  • Physics Backends: choosing and installing the Builtin or Jolt backend.

  • examples/features/3d/: collision_world.py (bodies + raycasting), raycast.py, joints.py.

  • examples/features/2d/: collision_shapes.py, area2d.py, joints.py.

  • examples/features/physics/: character_platformer.py (character controller), character_presence.py, playground3d.py, playground2d.py.

  • examples/demos/: physics_sandbox.py, waterfall.py (Jolt showcase).