Home/GANDER docs/GANDER physics

GANDER physics

2D physics (Box2D v3), 3D physics (Jolt), a walking character controller, cars with suspension and drivetrains, ragdolls and cloth.

physics.mdllms-full.txt

2D physics (Box2D v3), 3D physics (Jolt), a walking character controller, ragdolls and cloth. Everything is in gander:: (module physics.goose, part of the package). Bodies, joints, characters, ragdolls and cloth are handles: Body2, Joint2, Body, Joint, Character, Ragdoll, Cloth (.id == 0 is none).

Rules that bite

  • Physics only moves when you step it. Call gander::physics2d_step(dt) (2D) and/or gander::physics_step(dt) (3D) once per frame, before reading positions and before drawing. Both run fixed 1/60 s ticks inside, so the simulation is frame-rate independent and deterministic in gander test.
  • Physics does not draw. Each frame, read the body's position and draw your sprite / mesh / model there (gander::body_xform(b) is a ready Mat4 for 3D; it has no scale, so multiply your mesh scale on the right: gander::mul(gander::body_xform(b), gander::scale(size))).
  • Shapes are created centered. body_box takes the full width/height in 2D but half extents in 3D.
  • Kinds: gander::STATIC (never moves), gander::DYNAMIC (simulated), gander::KINEMATIC (moved by you, pushes dynamic bodies).
  • Contact / sensor events cover only the latest step call: read them right after stepping, every frame.
  • gander::physics_debug_draw() (3D) draws every body's bounds: green awake, grey asleep. Use it when something falls through the floor.

2D (pixels, +Y down, radians clockwise)

Scale: 32 px = 1 m (physics2d_scale(px)), gravity 9.8 m/s^2 down (physics2d_gravity(x, y) in px/s^2). Defaults for new shapes: physics2d_defaults(density, friction, bounce) (1, 0.6, 0).

Create
body2d_box(x, y, w, h, kind) -> Body2 center + full size
body2d_circle(x, y, r, kind)
body2d_capsule(x, y, h, r, kind) standing capsule, total height h (characters)
body2d_polygon(x, y, points: f32[], kind) convex, 3..8 points (x0, y0, x1, y1...) relative to (x, y)
body2d_chain(points: f32[], looped) static ground polyline in world pixels (hills, caves), at least 4 points
body2d_sensor(x, y, w, h) static trigger area
body2d_add_box(b, ox, oy, w, h, angle), body2d_add_circle(b, ox, oy, r) compound shapes
body2d_destroy(b), body2d_valid(b)

State: body2d_pos(b) -> float2, body2d_angle, body2d_vel -> float2 (px/s), body2d_spin (rad/s), body2d_set_pos/_set_angle/_set_vel/_set_spin, body2d_impulse(b, ix, iy), body2d_impulse_at(b, ix, iy, px, py), body2d_force, body2d_torque, body2d_mass(b) -> f32. Behavior: body2d_fixed_rotation(b, true) (characters), body2d_gravity_scale, body2d_damping(b, linear, angular), body2d_bullet(b, true) (fast objects), body2d_friction, body2d_bounce, body2d_density, body2d_filter(b, category_bits, mask_bits), body2d_tag(b, i32) / body2d_get_tag(b), body2d_on_ground(b) -> bool (a contact pushing up from below within ~45 degrees).

Events and queries: - physics2d_contact_count(), physics2d_contact(i) -> Contact2 { a, b, x, y, nx, ny, began } (began false: they separated; normal points from a to b). - physics2d_sensor_count(), physics2d_sensor_event(i) -> Contact2 (a = the sensor, b = the visitor). - raycast2d(x1, y1, x2, y2, mask) -> Hit2 { body, x, y, nx, ny, fraction } (body.id == 0: no hit; mask 0 = all). - physics2d_overlap_box(x, y, w, h) -> count, physics2d_overlap_circle(x, y, r) -> count, then physics2d_overlap_body(i).

Joints (anchors in world pixels): joint2d_revolute(a, b, x, y), joint2d_distance(a, b, ax, ay, bx, by), joint2d_prismatic(a, b, x, y, axis_x, axis_y), joint2d_weld(a, b, x, y), joint2d_mouse(ground, b, x, y) + joint2d_target(j, x, y) (drag with the pointer), joint2d_motor(j, speed, max_force) (revolute rad/s, prismatic px/s), joint2d_limits(j, lo, hi) (radians / pixels), joint2d_destroy(j).

3D (meters, +Y up)

Gravity -9.81 (physics_gravity(v)).

Create
body_box(pos, half, kind) -> Body half extents
body_sphere(pos, r, kind)
body_capsule(pos, h, r, kind), body_cylinder(pos, h, r, kind) upright, total height h
body_convex(model, xf, kind), body_convex_mesh(mesh, xf, kind) convex hull of a model / mesh placed at xf (props, debris)
body_mesh(model, xf), body_mesh_mesh(mesh, xf) exact triangles, static only (levels, buildings, rocks)
body_heightfield(heights: f32[], samples_x, samples_z, cell, origin) static terrain, heights[z * samples_x + x], origin = sample (0, 0)

Density is water-like (1000 kg/m^3), so a 1 m box is 1000 kg; set real masses with body_mass(b, kg). Default friction 0.6, bounce 0.

State: body_xform(b) -> Mat4, body_pos, body_rot -> float4, body_vel, body_spin, body_set_pos/_set_rot/_set_xform/_set_vel/_set_spin, body_impulse(b, i), body_impulse_at(b, i, point), body_force, body_torque, body_move(b, pos, rot, dt) (kinematic: doors, platforms, elevators), body_friction, body_bounce, body_mass, body_damping(b, linear, angular), body_gravity_scale, body_ccd(b, true) (fast small things), body_sensor(b, true) (trigger, reported as contacts), body_tag(b, i32) / body_get_tag(b) (-1 for ragdoll parts), body_layer(b, l), body_wake(b), body_awake(b), body_destroy(b), body_valid(b).

Layers 0..15: LAYER_STATIC 0 (static bodies), LAYER_DYNAMIC 1 (everything else), LAYER_CHARACTER 2, LAYER_RAGDOLL 3, LAYER_DEBRIS 4 (hits only static), 5..15 yours. All pairs collide except static/static, ragdoll/character and debris/non-static; change with physics_collide(a, b, on). Query masks are layer bits (1 << layer), 0 = all.

Queries: - raycast(origin, dir, max_dist, mask, ignore) -> RayHit { body, dist, point, normal } (dist < 0: no hit; ignore: a body to skip, e.g. character_body(player)). raycast(origin, dir, max_dist) hits anything. - spherecast(origin, radius, dir, max_dist, mask, ignore) -> RayHit. - physics_overlap_sphere(center, r, mask) -> count then physics_overlap_body(i). - physics_contact_count(), physics_contact(i) -> Contact { a, b, point, normal, speed, began } (speed = closing speed: play an impact sound when it is above ~2).

Joints (world anchors; gander::NO_BODY as b attaches to the world): joint_hinge(a, b, point, axis, min_deg, max_deg) (min >= max: free), joint_fixed(a, b), joint_point(a, b, point), joint_cone(a, b, point, twist_axis, swing_deg, twist_min, twist_max), joint_distance(a, b, pa, pb, min, max), joint_motor(j, rad_per_s, max_torque) (hinges), joint_destroy(j).

Character controller

character(feet, height, radius) -> Character: a capsule that walks. It steps up stairs (0.4 m), sticks to slopes up to 50 degrees, rides moving platforms, pushes dynamic bodies, collides with other characters, and has an inner body on LAYER_CHARACTER so raycasts can hit it.

  • character_update(c, move, jump, dt) once per frame: move = desired horizontal velocity (m/s, world), jump = upward speed when it should jump (only used on the ground, 0 otherwise). Gravity and air control are handled.
  • character_pos(c) (feet), character_vel, character_on_ground, character_ground_normal, character_set_pos, character_set_vel (knockback), character_height(c, h) -> bool (crouch; false when blocked above), character_body(c), character_destroy(c).
  • character_limits(c, step, max_slope_deg): the tallest step walked up (default 0.4 m) and the steepest slope walked on (default 50 degrees; crater banks 58).

Ragdolls

ragdoll(anim) -> Ragdoll lays 12 bodies on a character's skeleton by bone name (Hips, Spine*, Neck, Head, Left/Right Arm, ForeArm, Hand, UpLeg, Leg, Foot; Meshy, Mixamo, Unreal and Blender names all map): pelvis, spine, chest, head, upper and lower arms and legs, cone joints at hips / shoulders / spine / neck, hinges that bend only the natural way at knees and elbows.

  • Draw the character with draw_ragdoll(r, xf) always (alive too): it animates normally and records the pose so a ragdoll keeps the motion it had.
  • ragdoll_start(r, impulse, hit_point) on death: goes limp from the current pose, the impulse (N s; a rifle round ~40..80 along the bullet) goes into the body nearest hit_point.
  • ragdoll_state(r): RAGDOLL_ALIVE, RAGDOLL_FALLING, RAGDOLL_SETTLED (baked into a static pose after it rests, or after 4.2 s; costs nothing afterwards). At most 8 fall at once (ragdoll_max_active(n), 1..32); starting one more settles the oldest.
  • ragdoll_limits(r, part, swing, twist): joint limits in degrees for one part ("head", "l_upperarm", "r_shin" ...): swing is the cone half angle (for elbows and knees the largest bend), twist the turn about the bone; -1 keeps the default. ragdoll_damping(r, linear, angular) (0.1 / 0.6): higher falls limper and settles sooner. Both apply from the next ragdoll_start.
  • On a character whose model is still streaming (model_async) the ragdoll waits: its calls do nothing until the model arrives.
  • ragdoll_impulse(r, impulse, point) while falling (explosions), ragdoll_reset(r) back to animation (respawn).
  • ragdoll_bone_hit(r, point, out_name) -> part for hit-location damage: writes the bone name ("Head", "LeftLeg") and returns 0 pelvis, 1 spine, 2 chest, 3 head, 4/5 left arm, 6/7 right arm, 8/9 left leg, 10/11 right leg.
  • body_ragdoll(b) -> Ragdoll tells which ragdoll a raycast hit.

Cloth

Capes, flags, banners, curtains and tarps: a sheet simulated in physics_step (Jolt soft bodies). It is pushed by bodies, characters and ragdolls but never blocks them, and raycasts and shape casts pass through it, so a curtain never stops a player or a bullet.

  • cloth(ClothDesc { .. }, xf) -> Cloth makes the sheet in its pin frame xf: x across, hanging down -y, the front facing +Z.
  • width, height in metres.
  • pin: CLOTH_PIN_TOP (top edge centred on the frame: capes, curtains), CLOTH_PIN_LEFT (left edge, running out along +x: a flag from its pole), CLOTH_PIN_CORNERS (the two top corners: banners on hooks), CLOTH_PIN_NONE (nothing: it falls and drapes over what is below).
  • weight (kg/m^2: 0.1 silk, 0.25 a flag, 0.5 a heavy cape, 1 canvas), bend (0 drapes like silk .. 1 holds like canvas), damping, wind (how much cloth_wind moves it).
  • backstop: capes only (see below). detail: grid spacing, leave it 0.
  • swing_top, swing_bottom: swing limits for pinned sheets (Unreal's painted max distance): how far in metres each row may move from where it hangs in the pin's frame, from the pinned edge to the hem, linear down the rows. A cape: 0.03 at the shoulders, 0.5 at the hem, so it moves with the stride and the wind but never flies up over the head. -1 (default) off.
  • cloth_pin(c, xf): move the pinned edge. Every frame before physics_step for a cape; never for a flag that stays put. A jump of more than 4 m carries the whole sheet (respawn, teleport) instead of stretching it.
  • draw_cloth(c, material) every frame. Both sides are lit with any material; a texture's u runs across from the left edge, v down from the top.
  • draw_cloth_lined(c, outer, lining): instead of draw_cloth, a sheet with a lining: outer on the front (+Z as made: away from the wearer for a cape), lining on the back. Give both single-sided materials.
  • cloth_swing_scale(c, k): scale every swing limit at run time (1 as made, 1.5 looser while sprinting or in a gale, 0.5 tighter while kneeling).
  • cloth_wind(float3 m/s, gust 0..1): wind on every sheet (5 m/s makes a flag stand out, 12 is a gale; gust 0.5 gives ripples and gusts). Wind only pushes a sheet facing it: a flag side-on to the wind hangs.
  • cloth_push(c, at, radius, vel): shove it around a point (a shot through a flag, a blast, an NPC brushing a curtain).
  • cloth_point(c, u, v) -> float3: a point on the sheet as drawn (hang a lantern on a banner, put a sound on a flag's tip).
  • cloth_ignore(c, body): something the sheet passes through: a cape's wearer (character_body(player)), so it never fights the capsule.
  • cloth_capsule(c, bone_a, bone_b, radius) -> i32: a capsule between two bones of the wearer that the sheet stays out of (thighs, shins, arms, a sword; bone_b "" makes a sphere at bone_a). Make them once, up to 24.
  • cloth_wearer(c, anim, xf): every frame next to cloth_pin, the wearer's animator and the matrix it is drawn with, so the capsules follow its bones.
  • cloth_free(c).

Costs about 0.7 microseconds per vertex per frame. The default grid is 8 cm (5 cm in Pro builds, at most 32 x 32 vertices, 48 x 48 in Pro): a cape is about 0.1 ms, a 2 m curtain about 0.6 ms on the web. Keep it to a few sheets near the player, and cloth_free sheets far away.

Capes. Characters face -Z, so the cape goes on their back at +Z. Pin it with the same matrix you draw the character with, moved to the shoulders; use only the spine bone's position, so the cape hangs straight down from the character instead of twisting with every step of the animation. backstop (about 0.02) keeps the cape from swinging into the body, with no colliders on the body:

var cape: gander::Cloth;

fn cape_frame(a: gander::Anim, xf: gander::Mat4) -> gander::Mat4 {
    let s = gander::position(gander::bone(a, "spine_03"));   // "Spine2" on Mixamo rigs
    gander::mul(xf, gander::translate(gander::v3(0.0, s.y + 0.1, s.z + 0.17)))
}

// main: after the animator exists
cape = gander::cloth(gander::ClothDesc { width: 0.6, height: 1.05, pin: gander::CLOTH_PIN_TOP,
    weight: 0.5, bend: 0.2, backstop: 0.02 }, cape_frame(anim, hero_xf));
gander::cloth_ignore(cape, gander::character_body(player));

// frame
gander::cloth_pin(cape, cape_frame(anim, hero_xf));   // before physics_step
gander::physics_step(dt);
gander::draw_animated(anim, hero_xf);
gander::draw_cloth(cape, cape_mat);

A lined cape with a collar. Swing limits keep the hem's reach believable without colliders; the lining shows where the cape turns over; a collar is a second short, stiff sheet pinned a little higher (it is drawn over the cape's top edge):

cape = gander::cloth(gander::ClothDesc { width: 0.6, height: 1.05, weight: 0.5, bend: 0.2, backstop: 0.02,
    swing_top: 0.03, swing_bottom: 0.5 }, cape_frame(anim, hero_xf));
collar = gander::cloth(gander::ClothDesc { width: 0.5, height: 0.14, weight: 0.8, bend: 0.9,
    swing_top: 0.0, swing_bottom: 0.04 }, gander::mul(cape_frame(anim, hero_xf), gander::translate(gander::v3(0.0, 0.05, 0.02))));
// frame: pin both, then
gander::draw_cloth_lined(cape, wool_outer, linen_lining);
gander::draw_cloth_lined(collar, fur, fur);

A cape that wraps the body. backstop alone keeps a cape off the back, but a stride or a swing still passes through it. Capsules on the limbs make it drape over them instead: after every physics tick, any part of the sheet inside one is put on its surface (a fast swing cannot tunnel through). Size each a little wider than the limb with its armour; a sword's capsule goes from the hand to the blade's socket. Keep the cape's backstop small (0.02) with capsules: a loose one lets it swing forward into the legs and bunch. Keep the pinned edge out of every capsule (the engine warns when one holds it: narrow the cape or the capsule).

// main, after cloth(): legs, arms and the blade
gander::cloth_capsule(cape, "thigh_l", "calf_l", 0.10);
gander::cloth_capsule(cape, "thigh_r", "calf_r", 0.10);
gander::cloth_capsule(cape, "calf_l", "foot_l", 0.07);
gander::cloth_capsule(cape, "calf_r", "foot_r", 0.07);
gander::cloth_capsule(cape, "upperarm_r", "lowerarm_r", 0.07);
gander::cloth_capsule(cape, "hand_r", "blade_tip", 0.04);     // a socket (gander glb socket)

// frame, before physics_step
gander::cloth_pin(cape, cape_frame(anim, hero_xf));
gander::cloth_wearer(cape, anim, hero_xf);

Flag on a pole. The frame sits at the top of the pole; the flag runs out along +x and turns with the wind:

flag = gander::cloth(gander::ClothDesc { width: 1.8, height: 1.1, pin: gander::CLOTH_PIN_LEFT,
    weight: 0.2, bend: 0.15 }, gander::place(pole_top, 0.0));
gander::cloth_wind(gander::v3(6.0, 0.0, 1.0), 0.5);

Tarp over crates. CLOTH_PIN_NONE with the frame tipped flat (the sheet's down becomes +z), a metre above what it should cover:

let xf = gander::trs(gander::v3(x - 1.1, top + 1.0, z - 1.1),
    gander::quat_axis(gander::v3(1.0, 0.0, 0.0), -1.5708), gander::v3(1.0, 1.0, 1.0));
tarp = gander::cloth(gander::ClothDesc { width: 2.2, height: 2.2, pin: gander::CLOTH_PIN_NONE, weight: 0.8 }, xf);

Recipes

FPS player:

var player: gander::Character;
var yaw: f32 = 0.0;
var pitch: f32 = 0.0;
// main(): player = gander::character(gander::v3(0.0, 0.0, 0.0), 1.8, 0.3);
export "gander_frame" fn frame(dt: f32) {
    let look = gander::look_axis();
    yaw -= gander::deg(look.x);
    pitch = clamp(pitch - gander::deg(look.y), -1.4, 1.4);
    let mv = gander::move_axis();
    let fwd = gander::v3(-sin(yaw), 0.0, -cos(yaw));
    let right = gander::v3(cos(yaw), 0.0, -sin(yaw));
    let speed: f32 = if gander::action_down("sprint") { 6.5 } else { 4.0 };
    let jump: f32 = if gander::action_pressed("jump") { 5.0 } else { 0.0 };
    gander::character_update(player, (fwd * mv.y + right * mv.x) * speed, jump, dt);
    gander::physics_step(dt);
    let eye = gander::character_pos(player) + gander::v3(0.0, 1.65, 0.0);
    gander::camera_xform(gander::trs(eye, gander::quat_euler(pitch, yaw, 0.0), gander::v3(1.0, 1.0, 1.0)), 70.0);
}

Bullets (hitscan) with ragdoll deaths and headshots:

let eye = gander::camera_pos();
let dir = gander::camera_forward();
let hit = gander::raycast(eye, dir, 200.0, gander::MASK_ALL, gander::character_body(player));
// enemies are drawn with draw_ragdoll; find who was hit by distance to the hit point
// (or keep a capsule body per enemy on a game layer and use its tag)
var bone: u8[>..] = [];
let part = gander::ragdoll_bone_hit(enemy_doll, hit.point, bone);
if part == 3 { damage = 100; }
if enemy_hp <= 0 { gander::ragdoll_start(enemy_doll, dir * 60.0, hit.point); }

Platformer (2D):

// main(): hero = gander::body2d_capsule(80.0, 600.0, 56.0, 14.0, gander::DYNAMIC);
//         gander::body2d_fixed_rotation(hero, true);
let v = gander::body2d_vel(hero);
var vy = v.y;
if gander::action_pressed("jump") && gander::body2d_on_ground(hero) { vy = -420.0; }
gander::body2d_set_vel(hero, gander::move_axis().x * 260.0, vy);
gander::physics2d_step(dt);
let p = gander::body2d_pos(hero);   // draw the hero sprite at p

Coins / triggers (2D): coin = gander::body2d_sensor(x, y, 30.0, 30.0), then after stepping loop physics2d_sensor_event(i) and check e.began && e.a.id == coin.id.

Cars and trucks (3D): use the vehicle API, never hinge-jointed wheels (no suspension, they hop and flip). Build a dynamic chassis, then one call makes the whole car: suspension, engine with torque curve, automatic gearbox, differentials, anti-roll bars, tyre grip, lowered centre of mass.

car_body = gander::body_box(gander::v3(0.0, 1.5, 0.0), gander::v3(0.9, 0.4, 2.0), gander::DYNAMIC);
gander::body_mass(car_body, 1300.0);
// wheelbase 2.6, track 1.6, wheels at y -0.3 (chassis-local), radius 0.38, travel 0.3 m, all-wheel drive
car = gander::vehicle_car(car_body, 2.6, 1.6, -0.3, 0.38, 0.3, 2);
gander::vehicle_tune(car, 700.0, 7500.0, 32.0, 0.8);   // torque, redline, steering lock, grip (gravel)
gander::vehicle_suspension(car, 1.5, 0.5);             // soft rally springs

// every frame, before physics_step:
let m = gander::move_axis();
gander::vehicle_drive(car, m.y, m.x, if gander::action_down("brake") { 1.0 } else { 0.0 },
                      if gander::action_down("handbrake") { 1.0 } else { 0.0 });
gander::physics_step(dt);
// draw: the body from body_xform(car_body); each wheel (axle along X) from
// vehicle_wheel_xform(car, i), e.g. a wheel model authored axle-along-X, or
// MESH_CYLINDER times gander::rotate_z(1.5708) (its axis is Y)
for i: i32 in 0..4 { gander::draw_model(wheel, gander::vehicle_wheel_xform(car, i)); }
// HUD and feel: vehicle_speed (m/s, x 3.6 = km/h), vehicle_rpm (engine
// sound pitch), vehicle_gear; vehicle_wheel_slip(car, i) x/y past ~0.2/0.15
// = dust, skid marks and tyre squeal; vehicle_wheel_contact for airtime.

Forward is the chassis's -Z, wheels 0..3 are front left, front right, rear left, rear right. Throttle below zero reverses once stopped. Drive on a body_heightfield (terrain) or meshes: the wheels cast cylinders, so kerbs, rocks and ramps work.

Thrown objects / debris: body_convex(model, xf, gander::DYNAMIC), then body_impulse; put small debris on LAYER_DEBRIS so it never blocks players.

Terrain: build the height grid you render with, then body_heightfield(heights, nx, nz, cell, origin) so characters and ragdolls stand on it.