Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Physics: bodies that fall, stack and bounce

Crates that tumble, balls that bounce, doors that push things, triggers that notice who walked in: 2D rigid-body physics, built on rapier. It’s behind the physics feature:

ponko = { version = "0.0.1", features = ["physics"] }

(The C library, ponko_ffi, has it on by default.) For 3D bodies, terrain and a walking character, see 3D physics.

Turn it on with gravity in pixels per second² (y is down) and add the systems that react to it:

#![allow(unused)]
fn main() {
    Game::new("Physics", W as u32, H as u32)
        .physics(vec2(0.0, 980.0)) // pixels/s², y is down
        .sim_system(ring_the_bell)
}
    auto world = crabby::game("Physics", 1000, 700)
        .physics({0, 980})   // pixels/s², y is down
        .sim_system(ring_the_bell, "bell")

The physics step is a simulation system, placed where you call physics: systems added before it move things, systems added after it see the result.

Bodies and colliders

An entity becomes a physics body with three components: a Transform2D, a RigidBody (how it moves) and a Collider (its shape, in pixels):

#![allow(unused)]
fn main() {
fn build_level(ctx: &mut Ctx) {
    // Fixed bodies never move: the ground and two walls.
    for (at, size) in [
        (vec2(W / 2.0, H - 10.0), vec2(W, 20.0)),
        (vec2(10.0, H / 2.0), vec2(20.0, H)),
        (vec2(W - 10.0, H / 2.0), vec2(20.0, H)),
    ] {
        ctx.world
            .spawn((Transform2D::at(at), RigidBody::fixed(), Collider::rect(size)));
    }
    // A bouncy ball and a crate, both dynamic: gravity and collisions move them.
    ctx.world.spawn((
        Transform2D::at(vec2(300.0, 100.0)),
        RigidBody::dynamic(),
        Collider::circle(16.0).restitution(0.8),
    ));
    ctx.world.spawn((
        Transform2D::at(vec2(320.0, 0.0)),
        RigidBody::dynamic(),
        Collider::rect(vec2(40.0, 40.0)).friction(0.9),
    ));
    // A ramp: any convex shape of up to 8 corners, around its position.
    ctx.world.spawn((
        Transform2D::at(vec2(150.0, H - 80.0)),
        RigidBody::fixed(),
        Collider::polygon(&[vec2(-140.0, 60.0), vec2(140.0, 60.0), vec2(-140.0, -60.0)]),
    ));
    // A sensor reports overlaps but doesn't push.
    ctx.world.spawn((
        Transform2D::at(vec2(800.0, H - 60.0)),
        RigidBody::fixed(),
        Collider::rect(vec2(120.0, 80.0)).sensor(),
        Bell { rang: 0.0 },
    ));
}
}
struct Bell { float rang; };

static void build_level(crabby::world_view world) {
    // Fixed bodies never move: the ground.
    world.spawn(crabby::transform_at({500, 690}), crabby::fixed_body(), crabby::rect_collider({1000, 20}));
    // A bouncy ball and a crate, both dynamic: gravity and collisions move them.
    auto ball = crabby::circle_collider(16);
    ball.restitution = 0.8f;
    world.spawn(crabby::transform_at({300, 100}), crabby::dynamic_body(), ball);
    auto box = crabby::rect_collider({40, 40});
    box.friction = 0.9f;
    world.spawn(crabby::transform_at({320, 0}), crabby::dynamic_body(), box);
    // A ramp: any convex shape of up to 8 corners, around its position.
    world.spawn(crabby::transform_at({150, 620}), crabby::fixed_body(),
                crabby::polygon_collider({{-140, 60}, {140, 60}, {-140, -60}}));
    // A sensor reports overlaps but doesn't push.
    auto bucket = crabby::rect_collider({120, 80});
    bucket.sensor = true;
    world.spawn(crabby::transform_at({800, 640}), crabby::fixed_body(), bucket, Bell{0});
}
RustC++Meaning
RigidBody::dynamic()crabby::dynamic_body()moved by gravity and collisions
RigidBody::fixed()crabby::fixed_body()never moves: ground, walls
RigidBody::kinematic()crabby::kinematic_body()moved only by you; pushes, isn’t pushed
Collider::circle(r)crabby::circle_collider(r)
Collider::rect(size)crabby::rect_collider(size)
Collider::capsule(h, r)crabby::capsule_collider(h, r)vertical, good for characters
Collider::polygon(&points)crabby::polygon_collider({points...})convex, 3 to 8 corners: ramps, rocks, ships
.density(d), .friction(f), .restitution(r)fields of the same namemass per pixel², 0 ice..1 rubber, bounciness
.sensor().sensor = truereports overlaps, doesn’t push
.offset(v).offset = vshape moved from the entity’s position

A polygon is the convex hull of its corners, given around the entity’s position in any order; collider.shape.points() reads them back (C++: world.collider_points(e)). For a concave shape, such as an L, split it into convex pieces on separate bodies.

A rigid body also has gravity_scale, linear_damping, angular_damping, lock_rotation (characters that shouldn’t tip over) and ccd (continuous collision detection, so bullets don’t pass through thin walls).

Transform2D stays the source of truth. After each step, dynamic bodies write their new position and rotation into it, so sprites, attachments, the spatial grid and everything else just follow. Set a transform yourself and the body teleports there. Change a RigidBody or Collider and the body is rebuilt; despawn the entity (or remove either component) and it’s gone.

Add a Velocity component to read a body’s speed after every step, or to set it (a jump, a thrown crate, a moving platform):

#![allow(unused)]
fn main() {
ctx.world.insert(player, Velocity::linear(vec2(0.0, -400.0))); // jump: y is down
}

In C++, crabby::velocity is a plain struct, so it also works as a query column. Bodies and colliders need converting, so like sprites they’re read and written with get/set, not queried.

Collisions

When two colliders start or stop touching, the step sends a CollisionStarted or CollisionStopped event. sensor tells overlaps with a sensor apart from real contacts:

#![allow(unused)]
fn main() {
fn ring_the_bell(ctx: &mut Ctx) {
    let mut rang = false;
    for c in ctx.events.read::<CollisionStarted>() {
        rang |= c.sensor && [c.a, c.b].iter().any(|&e| ctx.world.has::<Bell>(e));
    }
    let dt = ctx.dt();
    for bell in ctx.world.query::<&mut Bell>().iter() {
        bell.rang = if rang { 1.0 } else { (bell.rang - dt * 2.0).max(0.0) };
    }
}
}
static void ring_the_bell(crabby::ctx& ctx) {
    auto world = ctx.world();
    bool rang = false;
    for (const crabby::collision& c : ctx.collisions_started())
        rang |= c.sensor && (world.has<Bell>(crabby::bits(c.a)) || world.has<Bell>(crabby::bits(c.b)));
    world.each([&](Bell& bell) { bell.rang = rang ? 1.0f : std::max(0.0f, bell.rang - ctx.dt() * 2); });
}

Like all events, each system sees each collision once.

Rays, overlaps and pushes

ctx.physics answers questions about the world as of the last step, and gives bodies a push:

#![allow(unused)]
fn main() {
fn blast(ctx: &mut Ctx) {
    if !ctx.key_pressed(Key::Space) {
        return;
    }
    let center = ctx.mouse_world();
    let mut near = Vec::new();
    ctx.physics.entities_in_circle(center, 200.0, &mut near);
    for e in near {
        let Some(t) = ctx.world.get::<Transform2D>(e) else {
            continue;
        };
        let away = (t.position - center).normalize_or(Vec2::NEG_Y);
        if let Some(mass) = ctx.physics.mass(e) {
            ctx.physics.apply_impulse(e, away * mass * 600.0);
        }
    }
}

/// What's under the mouse, straight down: a laser from the cursor.
fn laser(ctx: &mut Ctx) -> Option<RayHit> {
    ctx.physics.cast_ray(ctx.mouse_world(), vec2(0.0, 1.0), H)
}
}
static void blast(crabby::ctx& ctx, crabby::vec2 center) {
    std::vector<CrabbyEntity> near;
    ctx.entities_in_circle(center, 200, near);
    for (CrabbyEntity found : near) {
        const crabby::entity e = crabby::bits(found);
        const crabby::vec2 away = crabby::normalize_or(ctx.world().get<crabby::transform2d>(e).position - center, {0, -1});
        if (auto mass = ctx.mass(e)) ctx.apply_impulse(e, away * *mass * 600.0f);
    }
}

static std::optional<crabby::ray_hit> laser(crabby::ctx& ctx, crabby::vec2 from) {
    return ctx.cast_ray(from, {0, 1}, 700);   // what's straight below
}
  • cast_ray(origin, direction, max_distance): the first collider hit, with the point, the surface normal and the distance.
  • entities_at(point, &mut out) and entities_in_circle(center, r, &mut out): everything there. out is reused, so no allocation per call.
  • apply_impulse(e, impulse): a one-off push in mass × pixels/s (multiply by mass(e) to get a speed change).
  • set_gravity(g): change the pull for everyone.

New bodies join at the next step, so a query in the tick you spawned them won’t see them yet.

Determinism and cost

The same start gives the same result, tick after tick, on one machine: the physics step runs inside the simulation clock with a fixed time step. For bit-identical results across different CPUs and operating systems (lockstep multiplayer, replays), use the physics-determinism feature instead, which turns on rapier’s cross-platform determinism at a small speed cost.

Bodies cost only where they exist: entities without a RigidBody are never looked at, so the million-entity paths stay as fast as before. Compared with calling rapier directly, Crabby’s syncing adds about 3–8% (cargo run --release -p ponko_bench -- physics):

Boxes falling into a pile, 300 ticksCrabbyrapier alone
1,00035.1 ms32.6 ms
10,000~1.9 s~1.8 s

Try it: cargo run --release --example physics --features physics. Left click drops crates, right click balls, Space blasts things away.