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});
}
| Rust | C++ | 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 name | mass per pixel², 0 ice..1 rubber, bounciness |
.sensor() | .sensor = true | reports overlaps, doesn’t push |
.offset(v) | .offset = v | shape 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)andentities_in_circle(center, r, &mut out): everything there.outis reused, so no allocation per call.apply_impulse(e, impulse): a one-off push in mass × pixels/s (multiply bymass(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 ticks | Crabby | rapier alone |
|---|---|---|
| 1,000 | 35.1 ms | 32.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.