Skip to content

forge3d.World

The central object of every simulation. It manages rigid bodies, advances physics, and produces SceneSnapshots for rendering.


Class reference

World

forge3d physics world — minimal public API.

Coordinate system: z-up, SI units. Start here::

world = forge3d.World()
world.add_ground()
box = world.add_box(size=(1, 1, 1), position=(0, 0, 5))
world.step()          # advances by default_dt=1/60 s
snap = world.snapshot()

The internal PhysicsWorld is accessible via world._physics for advanced use, but the public API never needs it.

bodies property

bodies: list[Body]

All body handles currently in the world (order: insertion).

time property

time: float

Elapsed simulation time in seconds.

profiler property

profiler: Any

Lazy-created :class:~forge3d.profiler.PhysicsProfiler for this world.

Usage::

with world.profiler:
    world.step(dt)

print(world.profiler.last)

__init__

__init__(gravity: Any = (0.0, 0.0, -9.81)) -> None

add_ground

add_ground(material: Material | str = 'ground', size: Any = (40.0, 40.0, 0.2), height: float = 0.0) -> Body

Add a static ground plane. By default: 40×40 m slab at z=0.

add_box

add_box(size: Any = (1.0, 1.0, 1.0), position: Any = (0.0, 0.0, 0.0), mass: float = 1.0, material: Material | str = 'default', name: str = '', restitution: float = 0.3, friction: float = 0.5, static: bool = False) -> Body

Add a box-shaped rigid body.

Parameters

static : If True, creates an immovable static box (mass is ignored).

add_static_box

add_static_box(size: Any = (1.0, 1.0, 1.0), position: Any = (0.0, 0.0, 0.0), material: Material | str = 'default', name: str = '', restitution: float = 0.3, friction: float = 0.5) -> Body

Add a static (non-moving) box and register it in world.bodies.

Equivalent to add_box(..., static=True) — exposes _physics.add_static_box as a properly tracked public method.

add_sphere

add_sphere(radius: float = 0.5, position: Any = (0.0, 0.0, 0.0), mass: float = 1.0, material: Material | str = 'default', name: str = '', restitution: float = 0.3, friction: float = 0.5, static: bool = False) -> Body

Add a sphere-shaped rigid body.

static=True creates a non-moving marker (e.g. target visualization).

add_capsule

add_capsule(radius: float = 0.2, half_length: float = 0.5, position: Any = (0.0, 0.0, 0.0), quat: Any = None, mass: float = 1.0, material: Material | str = 'default', name: str = '', restitution: float = 0.3, friction: float = 0.5, static: bool = False) -> Body

Add a capsule-shaped rigid body (cylinder + two hemispherical caps).

The capsule axis is aligned with body-local +Z. Use quat to orient it.

Parameters

static : If True, creates an immovable static capsule.

add_cylinder

add_cylinder(radius: float = 0.5, half_length: float = 0.5, position: Any = (0.0, 0.0, 0.0), quat: Any = None, mass: float = 1.0, material: Material | str = 'default', name: str = '', restitution: float = 0.3, friction: float = 0.5, static: bool = False) -> Body

Add a cylinder-shaped rigid body (flat end caps, axis along +Z).

The cylinder has radius radius and total length 2 × half_length. Use quat to orient it — e.g. quat=[0.707,0.707,0,0] lays it on its side along the X axis.

Physics uses GJK/EPA convex-hull collision (same as add_mesh).

Parameters

radius : Cylinder radius (m). half_length : Half the cylinder height (m).

add_cone

add_cone(radius: float = 0.5, height: float = 1.0, position: Any = (0.0, 0.0, 0.0), quat: Any = None, mass: float = 1.0, material: Material | str = 'default', name: str = '', restitution: float = 0.3, friction: float = 0.5, static: bool = False) -> Body

Add a cone-shaped rigid body (circular base at z=−height/2, apex at z=+height/2).

Physics uses GJK/EPA convex-hull collision.

Parameters

radius : Base circle radius (m). height : Total height (m).

add_wedge

add_wedge(size: Any = (1.0, 1.0, 1.0), position: Any = (0.0, 0.0, 0.0), quat: Any = None, mass: float = 1.0, material: Material | str = 'default', name: str = '', restitution: float = 0.3, friction: float = 0.5, static: bool = False) -> Body

Add a wedge (right-angle triangular prism / ramp).

The ramp slopes from the front-bottom edge up to the back-top edge. size is (width_x, depth_y, height_z). Use quat to rotate.

Physics uses GJK/EPA convex-hull collision.

Parameters

size : (sx, sy, sz) total bounding dimensions (m).

Example::

ramp = world.add_wedge(size=(2, 3, 1), position=(0, 0, 0.5),
                       static=True)

add_convex

add_convex(vertices: Any, position: Any = (0.0, 0.0, 0.0), quat: Any = None, mass: float = 1.0, material: Material | str = 'default', name: str = '', restitution: float = 0.3, friction: float = 0.5, static: bool = False, collision_layer: int = 1, collision_mask: int = 65535) -> Body

Add a rigid body from a custom convex point cloud.

Computes the convex hull of vertices, uses it for both rendering and physics. Ideal for simple custom shapes without loading a full OBJ file.

Parameters

vertices : (N, 3) array-like — point cloud in local frame.

Example::

import numpy as np
pts = np.array([
    [-1, -1, -1], [1, -1, -1], [1, 1, -1], [-1, 1, -1],
    [ 0,  0,  1],  # pyramid apex
], dtype=float)
pyramid = world.add_convex(pts, position=(0, 0, 2), mass=1.0)

add_mesh

add_mesh(mesh_data: Any, position: Any = (0.0, 0.0, 0.0), quat: Any = None, mass: float = 1.0, material: Material | str = 'default', name: str = '', restitution: float = 0.3, friction: float = 0.5, static: bool = False, collision_layer: int = 1, collision_mask: int = 65535) -> Body

Add a convex-hull rigid body from a MeshData object.

Use collision_mask=0 for visual-only decorative bodies that should not participate in physics collision checks (trees, rocks, props). This is important for performance: mesh GJK is expensive and decorative bodies with large AABBs would otherwise be checked every frame.

Typical use::

from forge3d.io import load_obj
mesh = load_obj("assets/models/cube.obj")
body = world.add_mesh(mesh, position=(0, 0, 3), mass=1.0)

add_terrain

add_terrain(heights: Any, cell_size: float = 1.0, origin: Any = (0.0, 0.0, 0.0), material: Material | str = 'ground', friction: float = 0.8, layer: int = 8) -> Any

Add a heightfield terrain (static, collision-only).

Parameters:

Name Type Description Default
heights Any

2D array of shape (rows, cols) with z-heights in metres.

required
cell_size float

World-space size of each grid cell (m).

1.0
origin Any

World-space position of the (0, 0) grid corner.

(0.0, 0.0, 0.0)
material Material | str

Surface material for rendering.

'ground'
friction float

Coulomb friction coefficient (default 0.8).

0.8
layer int

Collision layer bit-flag (default CollisionLayer.TERRAIN = 0x0008).

8

Returns:

Name Type Description
A Any

class:~forge3d.collision.heightfield.Heightfield object.

Example::

import numpy as np
rng = np.random.default_rng(42)
h = rng.uniform(0, 2, (32, 32)).astype(np.float32)
terrain = world.add_terrain(h, cell_size=0.5, origin=(-8, -8, 0),
                            friction=0.9, layer=f3d.CollisionLayer.TERRAIN)

add_character

add_character(position: Any = (0.0, 0.0, 2.0), height: float = 1.8, radius: float = 0.3, mass: float = 70.0, name: str = 'character', ground_layer_mask: int = 65535, ground_check_hz: float = 60.0) -> Any

Add a capsule-based character controller.

Returns a :class:~forge3d.character.CharacterController with move(), jump(), and glide() methods.

Parameters

position : Initial world position (3,). height : Total capsule height in metres (default 1.8 m). radius : Capsule radius in metres (default 0.3 m). mass : Body mass in kg (default 70.0). name : Body name for collision queries. ground_layer_mask : Layers considered "ground" for the raycast check.

Example::

cc = world.add_character(position=(0, 0, 2), height=1.8)

while viewer.is_open:
    cc.move(direction=(inp.dx, inp.dy, 0), speed=5.5, dt=viewer.dt)
    if inp.just_pressed("space"):
        cc.jump(impulse=6.4)
    world.step(viewer.dt)

print(cc.is_grounded)

add

add(obj: Any) -> Any

Add a Body or Robot to the world. Returns the object passed in.

remove

remove(body: Body) -> None

Remove a body from the simulation.

.. warning:: The Python Body object you hold becomes stale after this call — its _id no longer exists in the world. Any subsequent call to :meth:apply_impulse, :meth:teleport, etc. with the old handle will raise RuntimeError.

Always reassign (``ball = world.add_sphere(...)``) or guard with
:meth:`contains` before reusing a handle.
Parameters

body : Body handle returned by an add_* method.

clear

clear(keep_statics: bool = False) -> None

Remove all bodies from the world.

.. warning:: All body handles in scope become stale after this call — including variables like ball, ground, etc. created before clear(). Calling :meth:apply_impulse or reading body.position on a stale handle raises RuntimeError.

Always reassign every body reference after calling ``clear()``::

    world.clear(keep_statics=False)
    ball = world.add_sphere(...)   # new handle — old variable is now stale
Parameters

keep_statics : If True, static bodies (ground planes, robot links) are kept; only dynamic bodies are removed.

contains

contains(body: Body) -> bool

Return True if body is still present in this world.

Body handles become stale after :meth:remove or :meth:clear. Use this guard before calling any method that operates on the body::

if world.contains(ball):
    world.apply_impulse(ball, force * dt)
Parameters

body : Body handle to check.

get_body

get_body(name: str) -> Body

Return the first body with name.

Raises

KeyError If no body with that name exists.

step

step(dt: float | None = None, substeps: int = 1) -> None

Advance simulation by dt seconds (default: 1/60 s).

Parameters

dt : Time delta in seconds (default 1/60 s). substeps : Divide dt into this many equal sub-steps for stability. substeps=4 is recommended for fast or lightweight objects. Collision callbacks fire once per full step (not per sub-step).

Steps taken each call:

  1. Flush per-body force/torque accumulators.
  2. Apply per-body linear/angular damping.
  3. Physics integration (substeps times at dt/substeps each).
  4. Apply weld constraints.
  5. Dispatch collision events.

update

update(frame_dt: float) -> None

Fixed-timestep accumulator update — call once per rendered frame.

Internally accumulates frame_dt and calls :meth:step with fixed_dt repeatedly until the accumulated time is consumed. Leftover time carries over to the next call.

Configure via :attr:fixed_dt and :attr:max_substeps (set on the world instance)::

world.fixed_dt    = 1 / 120   # default 1/120 s
world.max_substeps = 8         # default 8 (caps spiral-of-death)

Example::

world = forge3d.World()
world.fixed_dt = 1 / 120

while viewer.is_open:
    world.update(viewer.dt)   # frame_dt varies; physics is stable
    viewer.draw()

snapshot

snapshot() -> Any

Build a SceneSnapshot for the current state (used by Viewer/Recorder).

apply_impulse

apply_impulse(body: Body, impulse: Any) -> None

Apply an instantaneous velocity impulse to body (Δv = impulse / mass).

Use this to apply per-frame forces from a game loop::

# Apply force F for time dt:
world.apply_impulse(ball, np.array([F_x, F_y, 0]) * dt)
world.step(dt)

.. note:: Raises RuntimeError if body has been removed from the world (via :meth:remove or :meth:clear). Guard with :meth:contains if the body's lifetime is uncertain::

    if world.contains(ball):
        world.apply_impulse(ball, force * dt)

teleport

teleport(body: Body, position: Any, quat: Any = None) -> None

Instantly move a body to a new position (and optionally orientation).

weld

weld(body: Body, anchor: Body, local_offset: Any = None, local_rotation: Any = None) -> None

Attach body kinematically to anchor (weld constraint).

After welding, body follows anchor rigidly each step(), preserving both relative position and relative orientation.

Parameters

local_offset : Position offset in anchor local frame. Computed automatically from current positions if omitted. local_rotation : Quaternion [w, x, y, z] expressing body's rotation relative to anchor. Computed automatically if omitted.

release

release(body: Body) -> None

Remove weld constraint from body (body resumes normal physics).

add_joint

add_joint(joint_type: str, body_a: Body, body_b: Body | None = None, anchor_a: Any = (0.0, 0.0, 0.0), anchor_b: Any = (0.0, 0.0, 0.0), axis: Any = (0.0, 0.0, 1.0), limits: tuple[float, float] | None = None, motor_velocity: float | None = None, motor_max_torque: float = 10.0, stiffness: float = 100.0, damping: float = 5.0, rest_length: float = 1.0, target_distance: float = 1.0) -> Any

Add a joint constraint between two bodies.

Parameters:

Name Type Description Default
joint_type str

One of "fixed", "ball", "hinge", "prismatic", "distance", "spring".

required
body_a Body

First body (required).

required
body_b Body | None

Second body. If None, the joint anchors body_a to a world-fixed point (anchor_b in world frame).

None
anchor_a Any

Attachment point in body_a local frame.

(0.0, 0.0, 0.0)
anchor_b Any

Attachment point in body_b local frame (or world frame if body_b is None).

(0.0, 0.0, 0.0)
axis Any

Hinge / slide axis in body_a local frame (used for "hinge" and "prismatic").

(0.0, 0.0, 1.0)
limits tuple[float, float] | None

Angular limits (rad) for hinge or distance limits (m) for prismatic.

None
motor_velocity float | None

Target velocity for hinge/prismatic motor.

None
motor_max_torque float

Torque cap for hinge motor (N·m).

10.0
stiffness float

Spring constant k (N/m) for spring joint.

100.0
damping float

Damping coefficient c (N·s/m) for spring joint.

5.0
rest_length float

Natural spring length (m) for spring joint.

1.0
target_distance float

Target distance (m) for distance joint.

1.0

Returns:

Name Type Description
A Any

class:forge3d.constraints.JointHandle (pass to

Any

meth:remove_joint to delete the joint).

Examples::

hinge = world.add_joint("hinge", door, frame,
                        anchor_a=(-0.5, 0, 0),
                        anchor_b=(0.5, 0, 0),
                        axis=(0, 0, 1))
spring = world.add_joint("spring", box, ceiling,
                         stiffness=200.0, damping=10.0,
                         rest_length=2.0)

remove_joint

remove_joint(handle: Any) -> None

Remove a joint by its handle (returned from :meth:add_joint).

set_camera

set_camera(position: Any, target: Any = (0.0, 0.0, 0.0), up: Any = (0.0, 0.0, 1.0), fov_deg: float = 45.0) -> None

Set the default camera pose for snapshots and the Viewer.

raycast

raycast(origin: Any, direction: Any, max_dist: float = 100.0, layer_mask: int = 65535) -> Any | None

Cast a ray from origin along direction and return the first hit.

Tests the ray against all physics bodies and heightfield terrain, and returns the closest intersection, or None if nothing is hit.

Parameters

origin : (3,) ray start in world frame (m). direction : (3,) ray direction — need not be normalised. max_dist : Maximum hit distance (m).

Returns

A :class:RayHit namedtuple with fields (body, point, normal, distance) or None. For terrain hits body is None.

Example::

hit = world.raycast((0, 0, 5), (0, 0, -1), max_dist=10)
if hit:
    name = hit.body.name if hit.body else "terrain"
    print(name, hit.distance)

raycast_all

raycast_all(origin: Any, direction: Any, max_dist: float = 100.0, layer_mask: int = 65535) -> list[Any]

Cast a ray and return all hits sorted by distance (closest first).

Parameters

origin, direction, max_dist : same as :meth:raycast. layer_mask : only bodies whose collision_layer & layer_mask != 0 are tested. Heightfields are tested when CollisionLayer.TERRAIN & layer_mask is nonzero.

Returns

List of :class:RayHit namedtuples (body, point, normal, distance). May be empty.

Example::

hits = world.raycast_all((0, 0, 5), (0, 0, -1), max_dist=20)
for hit in hits:
    print(hit.body.name, hit.distance)

overlap_sphere

overlap_sphere(center: Any, radius: float, layer_mask: int = 65535) -> list[Body]

Return all bodies whose origin is within radius of center.

Uses AABB/position check (fast) — not exact shape intersection.

Parameters

center : (3,) world-space position. radius : Search radius in metres. layer_mask : Filter by collision layer.

Example::

nearby = world.overlap_sphere(explosion_pos, radius=5.0)
for body in nearby:
    body.apply_force(...)

overlap_box

overlap_box(center: Any, half_extents: Any, orientation: Any = None, layer_mask: int = 65535) -> list[Body]

Return all bodies whose origin falls inside an AABB or OBB.

Parameters

center : (3,) world-space centre. half_extents: (3,) half-sizes of the query box. orientation : (4,) quaternion [w,x,y,z] rotating the box (None = axis-aligned). layer_mask : Filter by collision layer.

Example::

bodies_in_room = world.overlap_box(room_center, half_extents=(5, 5, 3))

save

save(path: Any) -> None

Save the current world state to a JSON file.

Parameters:

Name Type Description Default
path Any

Output path (str or :class:pathlib.Path).

required

Example::

world.save("checkpoint.json")

load classmethod

load(path: Any) -> World

Load a world from a JSON file saved by :meth:save.

Returns a brand-new :class:World with all bodies restored::

world = forge3d.World.load("checkpoint.json")

To restore an existing world instance in-place, use :meth:restore instead::

world.restore("checkpoint.json")

Parameters:

Name Type Description Default
path Any

Path to a JSON file.

required

restore

restore(path: Any) -> None

Restore world state from path into this instance (clears existing bodies).

Unlike the classmethod :meth:load, this modifies the current world in-place so existing Python references to self remain valid::

world = forge3d.World()
world.restore("checkpoint.json")
print(len(world.bodies))  # bodies from the file

on_collision_begin

on_collision_begin(fn: Any) -> Any

Register a callback for when two bodies first collide.

Can be used as a decorator::

@world.on_collision_begin
def hit(event: forge3d.CollisionEvent) -> None:
    print(event.body_a.name, "hit", event.body_b.name)

on_collision_stay

on_collision_stay(fn: Any) -> Any

Register a callback called every step while two bodies remain in contact.

on_collision_end

on_collision_end(fn: Any) -> Any

Register a callback when two bodies separate.

add_collision_handler

add_collision_handler(body_a: Body, body_b: Body) -> Any

Return a :class:~forge3d.events.CollisionHandler for a specific body pair.

Example::

handler = world.add_collision_handler(ball, floor)
handler.on_begin = lambda e: print("Hit!")

ignore_collision

ignore_collision(body_a: Body, body_b: Body) -> None

Permanently ignore physics collisions between two specific bodies.

add_trigger_zone

add_trigger_zone(position: Any = (0.0, 0.0, 0.0), size: Any = (1.0, 1.0, 1.0), name: str = 'trigger', visual_material: Material | None = None) -> Any

Add an invisible trigger zone (no physics collision, events only).

Returns a :class:~forge3d.events.TriggerZone with on_enter and on_exit decorator attributes.

Parameters

visual_material : If supplied, a render-only box of the same size is created at position so the zone is visible in the scene without participating in physics.

Example::

goal = world.add_trigger_zone(position=(5, 0, 0.5), size=(1, 1, 1),
                              visual_material=f3d.Material(color="green"))

@goal.on_enter
def scored(body: forge3d.Body) -> None:
    print(f"GOAL! {body.name}")

Usage examples

Basic simulation loop

import forge3d as f3d

world = f3d.World(gravity=(0, 0, -9.81))
ground = world.add_ground()
box = world.add_box(size=(1, 1, 1), position=(0, 0, 5), mass=1.0)

for _ in range(600):        # 10 seconds @ 60 Hz
    world.step(dt=1/60)

print(f"Final z: {box.position[2]:.3f} m")

Static bodies (v1.1.0)

# All three create non-moving, collidable geometry — no mass needed
wall  = world.add_box(size=(10, 0.5, 3), position=(0, 5, 1.5), static=True)
cap   = world.add_capsule(radius=0.2, half_length=2, position=(0, 0, 3), static=True)
shelf = world.add_static_box(size=(4, 0.2, 0.1), position=(0, 0, 2))

Managing bodies

world.remove(box)
world.clear(keep_statics=True)   # remove dynamics only
print(world.time)                # elapsed simulation time (s)

Body handles become stale after remove / clear

The Python Body object you hold is invalidated when you call world.remove(body) or world.clear(). Any subsequent call that uses the old variable — apply_impulse, teleport, reading body.position, etc. — raises RuntimeError: Body id=N not found in world.

Pattern: always reassign after clear

world.clear(keep_statics=False)
ball = world.add_sphere(...)   # new handle — old 'ball' is now stale

Pattern: guard with world.contains()

if world.contains(ball):
    world.apply_impulse(ball, force * dt)

Weld constraints with rotation (v1.1.0)

parent = world.add_box(size=(2, 2, 1), position=(0, 0, 2), mass=10)
child  = world.add_box(size=(0.5, 0.5, 0.5), position=(1, 0, 2), mass=0.5)

# Classic weld — child follows parent position and orientation
world.weld(child, parent)

# Weld with fixed relative rotation (e.g. angled windmill blade at 90°)
world.weld(blade, hub, local_rotation=[0.707, 0, 0, 0.707])  # [w, x, y, z]

Raycast (v1.1.0)

hit = world.raycast(origin=(0, 0, 10), direction=(0, 0, -1), max_dist=15)
if hit:
    print(f"Hit {hit.body.name} at distance {hit.distance:.2f} m")
    print(f"Contact point: {hit.point}")
    print(f"Surface normal: {hit.normal}")

Heightfield terrain

import numpy as np

heights = np.random.default_rng(42).uniform(0, 3, (32, 32)).astype(np.float32)
terrain = world.add_terrain(
    heights=heights,
    cell_size=2.0,
    origin=(-32, -32, 0),
    material=f3d.Material(color=(0.3, 0.45, 0.2), roughness=0.9),
    friction=0.9,                         # terrain-specific friction
    layer=f3d.CollisionLayer.TERRAIN,     # assign to TERRAIN layer
)
# terrain is visible in Viewer and collidable (sphere + box vs heightfield).

Built-in primitive shapes

import forge3d as f3d

world = f3d.World(gravity=(0, 0, -9.81))
world.add_ground()

# Cylinder — aligned along +Z; half_length is half the height
pillar = world.add_cylinder(radius=0.3, half_length=1.0,
                             position=(0, 0, 1), static=True)
drum   = world.add_cylinder(radius=0.5, half_length=0.4,
                             position=(2, 0, 2), mass=2.0)

# Cone — base at bottom (-Z), apex at top (+Z)
cone = world.add_cone(radius=0.4, height=0.8, position=(0, 0, 2), mass=1.0)

# Wedge (triangular prism / ramp) — slant runs front-low → back-high (+Y rises)
ramp = world.add_wedge(size=(2.0, 1.5, 0.6), position=(0, 3, 0.3), static=True)

# Convex hull from arbitrary point cloud
import numpy as np
pts = np.array([
    [1, 0, 0], [-1, 0, 0],
    [0, 1, 0], [0, -1, 0],
    [0, 0, 1], [0, 0, -1],
], dtype=float)                                     # octahedron
gem = world.add_convex(pts, position=(0, 0, 3), mass=1.5)

# All add_* methods share the same common keyword arguments:
#   mass, static, restitution, friction, material,
#   collision_layer, collision_mask, name

Wedge orientation

The ramp face runs from low (−Y side) to high (+Y side). Rotate via quat= to orient the slope in any direction.

Mesh bodies and collision filtering

from forge3d.io import load_obj

mesh = load_obj("assets/tree.obj")

# Decorative prop — visible but skips all collision checks (mask=0)
tree = world.add_mesh(mesh, position=(5, 3, 0), static=True,
                      collision_mask=0)

# Solid obstacle on the DEFAULT layer
rock = world.add_mesh(mesh, position=(0, 0, 0), mass=5.0)

# Sensor-only trigger (detected by PLAYER but doesn't block movement)
pickup = world.add_mesh(mesh, position=(2, 0, 0), static=True,
                        collision_layer=f3d.CollisionLayer.TRIGGER,
                        collision_mask=f3d.CollisionLayer.PLAYER)

Joints

hinge = world.add_joint(
    "hinge", door, frame,
    anchor_a=(-0.5, 0, 0), anchor_b=(0, 0, 0),
    axis=(0, 0, 1),
    limits=(-1.5, 0.0),
    motor_velocity=1.0,
    motor_max_torque=30.0,
)
spring = world.add_joint(
    "spring", box, ceiling,
    stiffness=200.0, damping=10.0, rest_length=2.0,
)
world.remove_joint(hinge)

Fixed timestep (stable physics)

# Option A: world.update() — accumulates wall time, steps at fixed intervals
world.fixed_dt    = 1 / 120   # physics runs at 120 Hz (default)
world.max_substeps = 8         # cap spiral-of-death

while viewer.is_open:
    world.update(viewer.dt)   # call once per rendered frame
    viewer.draw()

# Option B: world.step(substeps=4) — split one frame into 4 sub-steps
world.step(dt=viewer.dt, substeps=4)

Spatial queries

# Multi-hit raycast (all intersections, sorted by distance)
hits = world.raycast_all(
    origin=(0, 0, 10),
    direction=(0, 0, -1),
    max_dist=20.0,
    layer_mask=f3d.CollisionLayer.ALL,
)
for hit in hits:
    print(hit.body.name, hit.distance)

# Overlap queries — find bodies within a region
nearby  = world.overlap_sphere(center=explosion_pos, radius=5.0)
in_room = world.overlap_box(center=room_center, half_extents=(5, 5, 3))
for body in nearby:
    body.apply_force((0, 0, 300))   # explosion push

Character controller

cc = world.add_character(
    position=(0, 0, 2),
    height=1.8,
    radius=0.3,
    mass=70.0,
)

while viewer.is_open:
    dx = inp.axis("right") - inp.axis("left")
    cc.move(direction=(dx, 0, 0), speed=5.5, dt=viewer.dt)
    if inp.just_pressed("space") and cc.is_grounded:
        cc.jump(impulse=6.0)
    world.step(viewer.dt)

Physics profiler

world.profiler.step(dt=1/60)       # measure one step

print(world.profiler.last)
# PhysicsProfile(total=1.23ms contacts=8)

# Or use as context manager
with world.profiler:
    world.step(dt=1/60)

avg = world.profiler.average(n=60)  # 1-second rolling average

Collision events

@world.on_collision_begin
def hit(event: f3d.CollisionEvent) -> None:
    print(event.body_a.name, "->", event.body_b.name,
          f"impact={event.relative_speed:.1f} m/s")

goal = world.add_trigger_zone(position=(5, 0, 0.5), size=(1, 1, 1))

@goal.on_enter
def scored(body: f3d.Body) -> None:
    print(f"Goal: {body.name}")

# Move or disable a trigger zone at runtime
goal.set_position((10, 0, 0.5))
goal.enabled = False   # pause detection without removing

Serialization

# Save bodies + joints
world.save("checkpoint.json")

# Load as a new World instance
world2 = f3d.World.load("checkpoint.json")

# Or restore an existing instance in-place
world.restore("checkpoint.json")
print(len(world.bodies))   # bodies from the file