Physics

SimVX has one physics stack for rigid bodies, character bodies, sensor zones, joints and spatial queries, in 2D and 3D. Every 3D class has a 2D sibling of the same shape (PhysicsBody3D / PhysicsBody2D, Area3D / Area2D, and so on), so the patterns below transfer by swapping the suffix and the vector type.

Two ideas carry the model:

  • A PhysicsRoot (or PhysicsRoot2D) owns an isolated simulation world for its branch of the tree. Bodies under it simulate in that world.

  • A body uses the world of its nearest PhysicsRoot ancestor, or a per-scene default world when there is none. In the simple case you add a PhysicsRoot, put bodies under it, and the fixed-step loop advances them.

Every node with a body in a world (PhysicsBody3D, CharacterBody3D, Area3D and their 2D siblings) derives from PhysicsObject3D / PhysicsObject2D, which is what an area’s body_entered and a query hit hand you.

This page is the guide. Backend choice, capability differences and the full knob reference are in Physics Backends. All names here are importable from simvx.core.

Bodies and modes

PhysicsBody3D (and PhysicsBody2D) is the one rigid-body node. Its mode is a BodyMode:

Mode

Behaviour

BodyMode.DYNAMIC

Simulated: gravity, impulses and contacts move it. Uses mass. The default.

BodyMode.STATIC

Immovable collider (floors, walls). Never integrated.

BodyMode.KINEMATIC

Moved by code with move_and_collide. Ignores gravity and forces, but pushes dynamic bodies.

mode can change at runtime (freeze a settled body to STATIC, wake a ragdoll to DYNAMIC), and the flip keeps the body’s mass. mass is in kilograms and is clamped into (0.001, 100000).

A body needs collision geometry: the shape property for a single collider, or CollisionShape3D children for a compound one (shape wins if both are present). A body with neither stays inert.

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

root = self.add_child(PhysicsRoot(name="World"))          # Y-up, gravity (0, -9.81, 0)
root.add_child(PhysicsBody3D(mode=BodyMode.STATIC, position=Vec3(0, -0.5, 0),
                             shape=BoxShape3D(half_extents=Vec3(20, 0.5, 20))))
root.add_child(PhysicsBody3D(mass=2.0, position=Vec3(0, 10, 0), shape=SphereShape3D(0.5)))

Every body property (mode, mass, shape, material, collision_layer, collision_mask, continuous, can_sleep, linear_damping, angular_damping, gravity_scale) takes effect live. The body is never rebuilt for a value change, so it keeps its pose, velocity and joints. A write wakes the body, and a write that can take support away (mode, mass, shape, the collision filter) also wakes whatever rests on it. Swapping shape is a teleport of the geometry: any new overlap is pushed apart over the next steps. A body cannot become shapeless.

continuous enables swept collision for fast bodies where the backend supports it (Capability.CONTINUOUS; see Physics Backends).

Shapes and materials are immutable values: to change one, assign a new one.

from dataclasses import replace

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

Parenting a body under a moving node

A body’s pose has one owner, and the mode says who:

  • A STATIC body follows its ancestors. Its pose is authored data, so moving a parent carries the collider with the sprite. Use this for authoring: a level chunk assembled under one origin, an obstacle nudged in the editor.

  • A DYNAMIC or KINEMATIC body does not. The simulation owns its pose; a parent’s move stops at the node’s transform.

So a platform that must carry or push bodies at runtime is not a parent of them. Make it a KINEMATIC body and move it with move_and_collide: a STATIC pose write is a teleport with no velocity, so riders would be left behind.

Collision shapes

Shapes are geometry resources, not nodes. Put one on a body with shape= or on a CollisionShape3D / CollisionShape2D child. A shape owns its backend collider, so one resource shared across many bodies costs one collider. Share the resource, not the numbers: two resources of the same size are two colliders.

3D 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 includes both caps)

CylinderShape3D

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

ConvexHullShape3D

points (4 or more); also ConvexHullShape3D.from_mesh(vertices)

ConcaveMeshShape3D

vertices, indices (a triangle list). STATIC bodies only.

2D 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 bodies only)

A CollisionShape3D with pickable=True receives CPU mouse picking (on_picked) through its bounding sphere; see Input. Picking is off by default and costs nothing when unused.

Scale

A body collides at its node’s world_scale: a node scaled to 4 has a collider four times the size. Scale belongs to the body, not the shape (a shape is shared), so a CollisionShape3D child’s own scale does not resize the collider. CollisionShape3D.collider_scale reports the scale the simulation applies.

Collider

Scale

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

per axis

cylinder

free along Y, one factor across X and Z

sphere, circle, capsule, segment

uniform only

A scale a collider cannot express raises ValueError naming the fix (scale uniformly, or use a box or hull). Negative components mirror and count as uniform, so a scale.x = -1 sprite flip is fine.

A change to a parent’s scale does not reach a child body’s collider until something writes that body’s pose.

Materials

A PhysicsMaterial holds the surface coefficients for contacts. Share one instance across bodies for a shared surface.

from simvx.core import PhysicsMaterial

rubber = PhysicsMaterial(friction=0.9, restitution=0.8)
ice = PhysicsMaterial(friction=0.05, restitution=0.0)
grippy = PhysicsMaterial(friction=0.9, friction_combine="max")

Field

Default

Meaning

friction

0.5

Coulomb friction coefficient (0 or more)

restitution

0.0

Bounciness, 0 to 1

friction_combine

CombineMode.AVERAGE

How the two bodies’ friction combines at a contact

restitution_combine

CombineMode.AVERAGE

How restitution combines, independently

CombineMode is AVERAGE, MIN, MAX or MULTIPLY, accepted by name ("max", "maximum", any case). When two materials ask for different modes, MAX > MIN > MULTIPLY > AVERAGE, so a deliberately grippy or bouncy surface dominates a neutral one.

Damping, gravity scale and world settings

Surface coefficients live on the material, per-object dynamics on the body, and settings for the whole space on the world. Defaults, and how each backend maps these, are in Physics Backends.

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 how fast a body sheds motion with nothing touching it, applied each step as v = v * max(0, 1 - damping * dt) + a * dt; 0 coasts forever.

  • gravity_scale multiplies the world’s gravity for one body: 0 ignores it, a negative value falls upward.

World settings are on the world a PhysicsRoot owns:

world = self.node_at("World").world
world.gravity = Vec3(0, -3.7, 0)      # Mars
world.solver_iterations = 24          # steadier tall stacks, at a cost while bodies are awake
world.position_iterations = 8         # tighter joint chains on the built-in solvers
world.sleep_time_threshold = 0.25     # park settled bodies sooner
world.sleep_velocity_threshold = 0.02
world.contact_slop = 0.001            # overlap left uncorrected

Forces, impulses and velocity

A body’s velocity is read and written directly:

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

Impulses apply at once; forces accumulate for the next fixed step and are then cleared, so a sustained force is re-applied every on_fixed_update:

ball.push(Vec3(0, 20, 0))                                         # 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))                                       # angular impulse

def on_fixed_update(self, dt):
    ball.add_force(Vec3(0, 0, -12))
    ball.add_torque(Vec3(0, 1, 0))

All four do nothing on STATIC and KINEMATIC bodies, or on a body that is not in the tree.

Sleeping

A dynamic body that stops moving falls asleep: it is skipped until something disturbs it, which lets a large resting scene cost almost nothing.

crate.is_sleeping           # True once it has settled
crate.wake()                # simulate it again now
crate.sleep()               # park it now
crate.can_sleep = False     # never park it
  • A pile sleeps and wakes as a unit. No body in a touching group parks until all of them are at rest, and a change that takes support away wakes the whole pile, so pulling out the bottom crate brings the stack down. STATIC and KINEMATIC bodies join no pile, so two stacks on one floor settle independently. A body with can_sleep = False keeps everything touching it awake.

  • A sleeping body is frozen: it reads back the same pose every step, so a save or screenshot of a settled scene is stable.

  • Every write that changes what the solver reads wakes the body. For a write that is bookkeeping (streaming a level in, respawning a pooled object), pass wake=False on the world call. It exists on the five calls that can take support away: set_body_transform, set_body_mode, set_body_mass, set_body_filter and set_body_shape.

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

set_body_mode(..., wake=False) to DYNAMIC hands the body over parked: dynamic, asleep, velocity zeroed, until something reaches it. A node does the same with its own calls, which keeps its mode Property in step:

crate.mode = BodyMode.DYNAMIC   # this write wakes it
crate.sleep()                   # so park it again before the next step

Sleeping needs Capability.SLEEP; on the pymunk backend nothing sleeps. See examples/features/physics/sleeping.py.

Contacts and signals

Every body emits collided when it starts touching another and separated when it stops. The payload is a Contact (3D) or Contact2D (2D): other is the peer body, and 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()

Prefer these signals to polling for overlaps.

A destroyed body is still named. A body destroyed while touching something sends its peer separated (and an overlapping area body_exited) on the next step, naming the detached node:

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 moved away

Its tree, handle and world are None and its properties are still readable. Read what you need and drop the reference. Both destroy() and remove_child() deliver the event.

Impact strength

Field

Meaning

Where

impulse

the normal impulse the solver applied

backends with Capability.CONTACT_IMPULSE; None elsewhere

impulse_estimate

the impulse that would arrest the approach, from the masses and the two bodies’ linear velocities

every backend, same formula

velocity

relative velocity at the contact point, spin included

every backend

impulse_estimate is the portable number for impact sounds and hit damage. It ignores restitution and is 0.0 for a pair that is not closing, so it measures impacts, not load. None from impulse is not 0.0, which is a real contact resolved with no push. Check support once at setup:

from simvx.core.physics.capability import Capability

has_impulse = Capability.CONTACT_IMPULSE in self.world.world.capabilities()   # self.world: a PhysicsRoot

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

Porting from Unity: Collision.relativeVelocity is the difference of the two linear velocities, while contact.velocity is measured at the contact point, so the two differ once a body spins. collided arrives after the step, when the impact has already been arrested; for the pre-impact speed along the normal against static geometry use impulse_estimate / mass, or snapshot velocity in on_fixed_update.

Character bodies

CharacterBody3D / CharacterBody2D is a KINEMATIC body with a swept movement helper. It has the same collision_layer and collision_mask as any body, so queries and areas see it, dynamic bodies rest on it, and two characters block each other. Set velocity and call move_and_slide(dt): it moves the body, slides the velocity along what it hits, and updates the grounded state.

from simvx.core import Axis1D, CharacterBody2D, Input, InputContext, Key, RectangleShape2D, Vec2

class Player(CharacterBody2D):
    input_context = InputContext({"walk": [Axis1D(low=Key.A, high=Key.D)],
                                  "jump": [Key.SPACE]}, name="player")

    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_fixed_update(self, dt):
        vx = Input.value("walk") * 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.just_pressed("jump") and self.is_on_floor():
            vy = -450.0
        self.velocity = Vec2(vx, vy)
        self.move_and_slide(dt)

Property

Default

Meaning

slope_limit

45

steepest walkable slope, degrees; also the ceiling threshold

step_height

0

highest step it climbs

max_slides

4

collide-and-slide iterations per move

skin_width

0.001

clearance the sweep leaves

mass

70

kg, used only for pushing

push_factor

1

how hard it shoves what it walks into; 0 turns pushing off

All are read per move, so they can change mid-game (a crouch lowering step_height). After a move, is_on_floor(), is_on_wall(), is_on_ceiling() and floor_normal report what was hit. up_direction classifies floor, wall and ceiling. velocity is a plain attribute: the character is moved by move_and_slide, not by the integrator.

Pushing

At the default push_factor = 1 a character walking into a crate shoves it. Each blocking contact except floor contacts and immovable bodies receives push_factor * (M * m / (M + m)) * approach_speed along the normal (M the character’s mass, m the crate’s), the impulse of a perfectly inelastic collision, so a crate never leaves faster than the character approached. Standing on a dynamic body imparts nothing to it. A character carries nothing: a body resting on it is not dragged along.

class Player(CharacterBody3D):
    def __init__(self, **kwargs):
        super().__init__(mass=80.0, push_factor=0.0, **kwargs)   # blocked by crates like walls

For custom responses, collisions holds the blocking contacts of the last move as SweepHits (body, point, normal, distance):

for hit in self.collisions:
    self.world.apply_impulse(hit.body, -hit.normal * 3.0, at=hit.point)

Layers in a character-dense scene

A sweep pays for every body its mask matches, so give projectiles, pickups and NPCs their own collision_layer and a collision_mask naming only what they should collide with (0 for nothing).

Kinematic movement

A KINEMATIC PhysicsBody3D moves with move_and_collide(velocity, dt=1.0): one sweep along velocity * dt that stops at the first blocker, updates the node at once, and returns the SweepHit (or None if it went the full distance). It does not slide or fall. Sensors, the mover itself, pairs the layers reject and surfaces the mover is moving away from never block it.

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 detects bodies passing through it and applies no collision response. It fires body_entered / body_exited with the peer body and area_entered / area_exited with the peer area, and keeps 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)

mobs = zone.get_overlapping_bodies(group="mobs")   # also type=; both filters AND
areas = zone.get_overlapping_areas()

An area detects a body when area.collision_mask & body.collision_layer is non-zero. monitoring=False makes it inert. Sleeping and STATIC bodies are detected too, without waking them; on Jolt an area whose collider is a ConcaveMeshShape3D is the exception (give a trigger convex geometry).

GravityArea3D / GravityArea2D adds gravity to the dynamic bodies inside it: gravity is a uniform acceleration, and point_gravity adds a pull toward the centre of strength point_strength (default 9.81).

Spatial queries

Ray and shape queries go through node.physics (3D) or node.physics_2d (2D), bound to the node’s world. Hits carry the scene node.

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], nearest first

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

earliest ShapeHit, or None; distance must be finite

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

the overlapping body nodes

node.physics resolves to the nearest PhysicsRoot ancestor. A node that creates a child PhysicsRoot and queries self.physics queries the wrong world; query through the root:

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

Layers and masks

Every body and area has a collision_layer (the layers it is on) and a collision_mask (the layers it scans), 32-bit integers defaulting to layer 1. Two bodies collide only when each opts in:

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

Queries and areas use a one-sided rule: a query considers a body when body.collision_layer & mask is non-zero.

Name layers with your own IntFlag; it is an int, so it stores and serialises 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, body_a and body_b. It resolves them when it enters the tree, so add a joint after both bodies are in the world; both must be in the same world.

3D joint

Constrains

FixedJoint3D

the full relative transform: the bodies move as one

PinJoint3D(anchor=...)

a shared world point; rotation stays free

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

a pin plus one free rotation axis (default Vec3(0, 1, 0))

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

a soft distance spring; -1 takes the current separation

The 2D set mirrors it (FixedJoint2D, PinJoint2D, HingeJoint2D, SpringJoint2D) and adds GrooveJoint2D, which keeps a point of one body on a segment of the other.

from simvx.core import HingeJoint3D, PinJoint3D, Vec3

root.add_child(PinJoint3D(body_a=anchor, body_b=bead, anchor=anchor.world_position))
root.add_child(HingeJoint3D(body_a=post, body_b=door, anchor=hinge_pos, axis=Vec3(0, 1, 0)))

A joint reads its parameters when it enters the tree; re-enter it to change one. The anchor is captured into each body’s own frame, so a pin on a spinning platform orbits with it on every backend. Joints have no motors, limits or breaking; drive a mechanism with add_torque. How firm joints are differs by backend: see Physics Backends.

Worlds and roots

Roots can nest, and the innermost wins, so a scene can run several independent worlds:

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 gravity Vec3(0, -9.81, 0). 2D worlds have no preferred axis; a game in screen pixels with +Y down uses a positive-Y gravity. PhysicsRoot(backend=...) picks a backend for that branch.

Fixed timestep

Physics advances on a fixed-step clock: put physics code in on_fixed_update(dt). Node hooks run first each step, then every world advances once. The application’s physics_fps sets the rate (60 by default):

App(title="Physics", physics_fps=120).run(GameScene())

Choosing a backend

The default 3D backend, BuiltinPhysics, is pure Python and runs everywhere, browser included. Its main limits are no inertia tensor (so it does not model rolling: a pushed ball slides) and box and hull collision that ignores rotation. Sliding boxes and characters behave the same as on a native solver. For rolling props, oriented collision, stable stacking and high body counts, select the native Jolt backend per root with PhysicsRoot(backend="jolt"). In 2D, installing pymunk makes Chipmunk2D the default. Physics Backends has the comparison.

See also

  • Physics Backends: selecting a backend, capability differences and the knob reference.

  • examples/features/3d/: collision_world.py, raycast.py, joints.py.

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

  • examples/features/physics/: character_platformer.py, character_presence.py, body_knobs.py, sleeping.py, playground3d.py, playground2d.py.

  • examples/demos/: physics_sandbox.py, waterfall.py.