— Algún desarrollador de Celeste, 2018, en una GDC
¿Sabías que *Celeste* (2018, Matt Makes Games — Maddy Thorson y Noel Berry) tiene **un personaje controller hecho a mano** y no usa ningún motor de física 2D? Matt Thorson lo contó en su GDC talk de 2019: preferían el control total de coyote time, jump buffering y corner correction a la flexibilidad de un motor genérico. ¿Consecuencia? El plataformero con mejor "game feel" de la historia usa ~300 líneas de código de física custom encima de AABB caseros. Lo vamos a replicar.
Este es el capítulo más largo del libro, y por una razón: la física 2D es donde se separa el "mi juego se mueve" del "mi juego se siente bien". Vamos a hablar de:
Si tu juego tiene un personaje, unas plataformas y algunos enemigos, probablemente no necesitas un motor de física. Necesitas un AABB y 50 líneas de integración. Sí, en serio. Celeste, Hollow Knight, Dead Cells, Hades — todos tienen física custom o usan muy poco de un motor.
Si tu juego tiene decenas de objetos interactuando, vehículos, cuerdas, fluidos, ragdolls — sí, necesitas un motor.
**Motor de física 2D**: librería externa que simula rigideces, colisiones y restricciones. Hace el trabajo sucio por ti. A cambio, te quita control fino sobre las decisiones de movimiento.
**Física custom**: código tuyo que decide cuándo y cómo se mueve cada entidad. Más trabajo, más control. Recomendable cuando el "game feel" es crítico.
Error clásico: meter bevy_rapier2d en un plataformas porque *"el motor es estándar"*. Tres meses después sigues peleándote con la detección de colisiones, el personaje se queda atascado en esquinas, y el jump buffer no funciona como quieres. Si tu juego es un plataformas, **escribe tu propia física**.
| Aspecto | avian2d | bevy_rapier2d |
|---|---|---|
| Mantenedor | comunidad Bevy (Jondolf y colaboradores) | Dimforge (Rapier ES la lib, escrita en Rust puro por Sébastien Crozet y equipo) |
| Versión actual (jul 2026) | 0.7.0 | 0.34.0 |
| Bevy compatible | 0.19 | 0.19 |
| Arquitectura | ECS puro desde cero en Rust | Bridge ECS ↔ lib Rapier (también en Rust) |
| Características | Rigid bodies, joints, sensores, character controller (MoveAndSlide) | Todo lo anterior + character controller propio, vehículos, ragdolls |
| Curva de aprendizaje | Baja (API ECS-first) | Media (conceptos Rapier: isla, conjunto de colisiones) |
Reglas de pulgar:
Aclaración histórica que se confunde a menudo: Rapier no es un "wrapper" sobre una lib C++. Rapier es la librería de Dimforge, escrita en Rust puro desde su origen. Lo que hace bevy_rapier2d es tender un puente entre la ECS de Bevy y esa librería Rust. Avian, por su parte, es ECS-nativo desde el primer commit (nació como bevy_xpbd). Ambos son Rust puro, ninguno tiene bindings a C++.
[dependencies]
bevy = { version = "0.19", features = ["wayland"] }
avian2d = "0.7" # 0.7.0, compatible con Bevy 0.19
use avian2d::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(PhysicsPlugins::default()) // ← PLURAL: PhysicsPlugins
.insert_resource(Gravity(Vec2::new(0.0, -980.0))) // px/s²
.add_systems(Startup, setup)
.run();
}
**PhysicsPlugins** (en PLURAL): el plugin raíz de Avian. Registra el loop de física, el broad phase, el narrow phase, la resolución de restricciones, joints y spatial queries. No es `PhysicsPlugin` (singular) — ese nombre no existe.
**Gravity**: aceleración aplicada a todos los `RigidBody::Dynamic`. Se mide en píxeles/segundo². En la Tierra real son 9.81 m/s². En tu juego, ajusta hasta que se sienta bien (típico: 800–1500).
En cualquier motor 2D hay tres tipos de cuerpo. Cada uno tiene un "contrato" distinto con la física:
Inmóvil. No se mueve nunca (las paredes, el suelo, los decorados). Más barato de procesar.
commands.spawn((
RigidBody::Static,
Collider::rectangle(200.0, 50.0),
Transform::from_translation(Vec3::new(0.0, -200.0, 0.0)),
));
Afectado por la gravedad y las fuerzas. Cosas que "viven" en el mundo: cajas, proyectiles, enemigos "tontos".
commands.spawn((
RigidBody::Dynamic,
Collider::circle(20.0),
Transform::from_translation(Vec3::new(0.0, 100.0, 0.0)),
LinearVelocity::default(),
));
Lo controlas tú. La física no lo afecta por gravedad, pero sí puede chocar con Dynamic. Plataformas móviles, ascensores, NPCs controlados por código.
commands.spawn((
RigidBody::Kinematic,
Collider::rectangle(80.0, 20.0),
Transform::from_translation(Vec3::new(100.0, 0.0, 0.0)),
LinearVelocity::new(Vec2::new(50.0, 0.0)), // te mueves tú con velocidad
));
Atención: en Avian 0.7 no existe un componente KinematicCharacterController (ese nombre es de bevy_rapier2d). Si quieres que Avian resuelva colisiones automáticamente para un personaje cinemático, usa MoveAndSlide (lo veremos en la sección 17.9). O, mejor todavía, escribe tu propio controller AABB (sección 17.8): es lo único que te da control absoluto del game feel.
**RigidBody::Static**: es un muro. El motor ni se molesta en calcular su posición cada frame (asume que no cambia). Perfecto para niveles.
**RigidBody::Dynamic**: tiene masa, velocidad, responde a fuerzas y choques. Lo más caro de simular, pero lo más flexible.
**RigidBody::Kinematic**: controlado por código. La física lo respeta (puede empujar a un Dynamic), pero no lo afecta. Ideal para personajes controlados directamente.
Collider::circle(20.0) // círculo de radio 20
Collider::rectangle(40.0, 60.0) // AABB 40x60
Collider::capsule(20.0, 30.0) // cápsula: radio 20, altura del cuerpo 30
Collider::triangle(
Vec2::new(0.0, 20.0),
Vec2::new(-20.0, -20.0),
Vec2::new(20.0, -20.0),
) // triángulo custom
Collider::convex_hull(vec![ // polígono convexo
Vec2::new(0.0, 30.0),
Vec2::new(25.0, 10.0),
Vec2::new(20.0, -25.0),
Vec2::new(-20.0, -25.0),
Vec2::new(-25.0, 10.0),
]).unwrap()
**AABB (Axis-Aligned Bounding Box)**: rectángulo alineado con los ejes. Es el collider más barato. No rota. Perfecto para plataformas cuadradas. En Avian se crea con `Collider::rectangle`.
**Círculo**: muy barato, ideal para bolas, enemigos redondos, monedas. Cuidado: en esquinas puede "rodar" un poco por inexactitud numérica.
**Cápsula**: medio círculo arriba, rectángulo en medio, medio círculo abajo. **SÍ existe en avian2d** como `Collider::capsule(radius, length)`. Es la forma recomendada para personajes humanoides: evita el "sticky corner" de un AABB puro y se desliza suavemente por paredes.
**Polígono convexo**: cualquier forma sin huecos ni entrantes. Más caro pero más preciso. Se crea con `Collider::convex_hull`.
**Polígono cóncavo**: cualquier forma. NO lo soporta casi ningún motor 2D. Lo que haces es descomponerlo en convexos (convex decomposition). Hay algoritmos automáticos para esto.
Para un personaje jugador, lo mejor es una cápsula. Collider::capsule está built-in en avian2d (no tienes que componerla manualmente): un círculo arriba, un rectángulo en medio y un círculo abajo, en una sola primitiva. Es lo que usaremos en los ejemplos siguientes.
Un sensor detecta cuándo otro collider entra en él, pero no bloquea el paso. Lo usas para:
commands.spawn((
RigidBody::Static,
Collider::circle(80.0),
Sensor, // ← clave: no bloquea, solo detecta
DamageZone { damage: 10 },
Transform::from_translation(Vec3::new(0.0, 50.0, 0.0)),
));
Avian expone los eventos de contacto como CollisionStart (singular) y CollisionEnd. A diferencia de lo que se ve en tutoriales antiguos, NO son tuplas: tienen campos con nombre entity1 y entity2.
use avian2d::prelude::*;
#[derive(Component)]
struct DamageZone { damage: u32 }
fn detect_damage(
mut events: EventReader<CollisionStart>, // ← SINGULAR: CollisionStart
zones: Query<&DamageZone>,
player_query: Query<Entity, With<Player>>,
mut health: Query<&mut Health, With<Player>>,
) {
let player = player_query.single();
for ev in events.read() {
// CollisionStart expone entity1 / entity2 (campos con nombre, no .0 / .1).
let other = if ev.entity1 == player { ev.entity2 } else { ev.entity1 };
if other == player { continue; }
if let Ok(zone) = zones.get(other) {
if let Ok(mut h) = health.get_mut(player) {
h.current = h.current.saturating_sub(zone.damage);
}
}
}
}
**CollisionStart / CollisionEnd** (en SINGULAR): eventos de avian2d que se disparan cuando dos colliders empiezan/dejan de estar en contacto. Tienen campos con nombre `entity1` y `entity2`, no tupla `.0`/`.1`. Tu código reacciona, no el motor.
Si todo choca con todo, tu motor se vuelve un caos. Los layers (capas de colisión) te dicen quién choca con quién. Típicamente:
Player choca con Solid, Enemy, Pickup.Enemy choca con Solid, Enemy (entre sí), Player.Pickup choca solo con Player.En Avian defines los layers con un enum y el derive PhysicsLayer (exportado por el crate), y los aplicas con CollisionLayers::new(memberships, filters): el primer argumento es a qué capa pertenezco, el segundo con qué capas quiero chocar.
use avian2d::prelude::*; // trae PhysicsLayer y CollisionLayers
#[derive(PhysicsLayer, Default, Debug, Clone, Copy)]
enum Layer {
#[default]
Player,
Enemy,
Solid,
Pickup,
Trigger,
}
// El jugador: pertenece a Player, colisiona con Solid, Enemy, Pickup, Trigger.
commands.spawn((
RigidBody::Dynamic,
Collider::capsule(20.0, 30.0),
CollisionLayers::new(
Layer::Player, // memberships: a qué capa pertenezco
[Layer::Solid, Layer::Enemy, Layer::Pickup, Layer::Trigger], // filters: con quién choco
),
));
// El suelo: pertenece a Solid, choca con Player, Enemy.
commands.spawn((
RigidBody::Static,
Collider::rectangle(2000.0, 50.0),
CollisionLayers::new(Layer::Solid, [Layer::Player, Layer::Enemy]),
));
// Una moneda: solo el jugador la toca.
commands.spawn((
Sensor,
Collider::circle(12.0),
CollisionLayers::new(Layer::Pickup, Layer::Player),
));
**CollisionLayers::new(memberships, filters)** + derive `#[derive(PhysicsLayer)]`: el primer argumento dice *a qué layer pertenezco* (acepta un único `Layer` o una lista). El segundo dice *con qué layers quiero chocar*. Si no estás en mi filter, te ignoro aunque pases por encima.
Aquí es donde se cocina el game feel. Un buen controller de plataformas tiene cuatro ingredientes mágicos:
Vamos a implementarlo sin motor (estilo Celeste), porque es la mejor forma de entenderlo. Y porque, sinceramente, es lo único del capítulo que compila y funciona sin pelearte con APIs de terceros: solo necesitas bevy, AABB caseros y 50 líneas. Si solo te llevas una sección de todo el capítulo, llévate esta.
use bevy::prelude::*;
#[derive(Component, Default)]
struct Player {
velocity: Vec2,
on_ground: bool,
coyote_timer: f32,
jump_buffer_timer: f32,
}
#[derive(Component)]
struct Solid;
const GRAVITY: f32 = -1500.0;
const JUMP_VELOCITY: f32 = 480.0;
const MOVE_SPEED: f32 = 220.0;
const COYOTE_FRAMES: f32 = 0.10; // ~6 frames @ 60fps
const JUMP_BUFFER_FRAMES: f32 = 0.10;
const MAX_FALL_SPEED: f32 = -800.0;
fn player_input(
keys: Res<ButtonInput<KeyCode>>,
mut query: Query<&mut Player>,
) {
let mut player = query.single_mut();
let mut dir_x = 0.0;
if keys.pressed(KeyCode::KeyA) { dir_x -= 1.0; }
if keys.pressed(KeyCode::KeyD) { dir_x += 1.0; }
player.velocity.x = dir_x * MOVE_SPEED;
// Jump buffer: si pulsas, anotas el "deseo de saltar".
if keys.just_pressed(KeyCode::Space) {
player.jump_buffer_timer = JUMP_BUFFER_FRAMES;
}
// Variable jump height: si sueltas el botón, cortas el ascenso.
if keys.just_released(KeyCode::Space) && player.velocity.y > 0.0 {
player.velocity.y *= 0.4;
}
}
fn player_physics(
time: Res<Time>,
mut query: Query<&mut Player>,
) {
let dt = time.delta_secs(); // ← delta_secs() en SINGULAR. NO existe delta_seconds().
let mut player = query.single_mut();
// 1) Gravedad.
player.velocity.y += GRAVITY * dt;
player.velocity.y = player.velocity.y.max(MAX_FALL_SPEED);
// 2) Coyote timer.
if player.on_ground {
player.coyote_timer = COYOTE_FRAMES;
} else {
player.coyote_timer -= dt;
}
// 3) Procesar salto (coyote + buffer).
if player.jump_buffer_timer > 0.0 && player.coyote_timer > 0.0 {
player.velocity.y = JUMP_VELOCITY;
player.jump_buffer_timer = 0.0;
player.coyote_timer = 0.0;
}
player.jump_buffer_timer -= dt;
}
//! AABB propio: dos esquinas (min, max). Lo definimos aquí para no depender
//! de ningún tipo de Avian/Rapier. Es struct pura.
#[derive(Clone, Copy, Debug)]
struct Aabb { min: Vec2, max: Vec2 }
impl Aabb {
fn from_center_half(center: Vec2, half: Vec2) -> Self {
Self { min: center - half, max: center + half }
}
fn intersects(self, other: &Self) -> bool {
self.min.x <= other.max.x
&& self.max.x >= other.min.x
&& self.min.y <= other.max.y
&& self.max.y >= other.min.y
}
}
const PLAYER_HALF: Vec2 = Vec2::new(8.0, 16.0);
const SOLID_HALF: Vec2 = Vec2::splat(25.0); // 50x50
fn player_collide_and_move(
time: Res<Time>,
mut player_query: Query<(&mut Transform, &mut Player)>,
solid_query: Query<&Transform, With<Solid>>,
) {
let dt = time.delta_secs(); // delta_secs(), no delta_seconds()
let (mut transform, mut player) = player_query.single_mut();
// Resolución simple de AABB: por eje, por separado.
// Horizontal primero, después vertical.
// ── Eje X ────────────────────────────────────────
let new_x = transform.translation.x + player.velocity.x * dt;
let center = Vec2::new(new_x, transform.translation.y);
let proposed = Aabb::from_center_half(center, PLAYER_HALF);
let mut collided_x = false;
for solid_t in &solid_query {
let solid_aabb = Aabb::from_center_half(
solid_t.translation.truncate(),
SOLID_HALF,
);
if proposed.intersects(&solid_aabb) {
collided_x = true;
break;
}
}
if !collided_x {
transform.translation.x = new_x;
} else {
player.velocity.x = 0.0;
}
// ── Eje Y ────────────────────────────────────────
let was_on_ground = player.on_ground;
player.on_ground = false;
let new_y = transform.translation.y + player.velocity.y * dt;
let center = Vec2::new(transform.translation.x, new_y);
let proposed = Aabb::from_center_half(center, PLAYER_HALF);
let mut collided_y = false;
for solid_t in &solid_query {
let solid_aabb = Aabb::from_center_half(
solid_t.translation.truncate(),
SOLID_HALF,
);
if proposed.intersects(&solid_aabb) {
collided_y = true;
break;
}
}
if !collided_y {
transform.translation.y = new_y;
} else {
// Si veníamos cayendo, hemos tocado suelo.
if player.velocity.y < 0.0 && !was_on_ground {
player.on_ground = true;
}
player.velocity.y = 0.0;
}
}
**AABB propio**: rectángulo definido por esquinas mínima y máxima. La intersección entre dos AABBs es trivial de calcular: comparas si sus rangos se solapan en X y en Y. Definirlo tú mismo (en vez de depender del tipo `Aabb` de Avian/Rapier) te da un controller que **compila sin ninguna dependencia de motor**.
**Resolución por ejes separados**: mueves primero en X y compruebas colisión; luego en Y. Esto evita el "atasco en esquinas" típico de moverte en diagonal.
Truco de corner correction: si estás colisionando en ambos ejes y estás contra una esquina, mueve 1-2 píxeles perpendiculares al movimiento antes de bloquear.
¿Sabías que el término **"coyote time"** viene del "*Wile E. Coyote* y el Correcaminos" (1949, Warner Bros — Chuck Jones)? El coyote caminaba sobre el aire unos instantes antes de caer. Maddy Thorson popularizó el nombre en *Celeste* (2018) tras leer un tweet de 2015 de @jessecarriere. 76 años lleva el coyote definiendo el "game feel" sin saberlo.
MoveAndSlideSi decides usar avian2d pero quieres que el motor te resuelva las colisiones automáticamente (sin escribir tu propio AABB), la herramienta de Avian 0.7 es MoveAndSlide, en el módulo avian2d::character_controller::move_and_slide. Es el equivalente avian al KinematicCharacterController de Rapier, pero con una API distinta.
use avian2d::prelude::*;
use avian2d::character_controller::move_and_slide::{MoveAndSlide, MoveAndSlideConfig};
// Spawn del personaje: RigidBody::Dynamic + Collider + MoveAndSlide.
commands.spawn((
RigidBody::Dynamic,
Collider::capsule(10.0, 28.0),
MoveAndSlide::default(), // ← controller de Avian 0.7
LinearVelocity::default(),
Transform::default(),
));
// En un sistema, le das la velocidad deseada y MoveAndSlide resuelve colisiones:
fn move_player(
keys: Res<ButtonInput<KeyCode>>,
time: Res<Time>,
mut query: Query<&mut LinearVelocity, With<MoveAndSlide>>,
) {
let mut vel = query.single_mut();
let mut dir = Vec2::ZERO;
if keys.pressed(KeyCode::KeyA) { dir.x -= 1.0; }
if keys.pressed(KeyCode::KeyD) { dir.x += 1.0; }
// MoveAndSlide lee LinearVelocity (y/o un `MoveAndSlideConfig::acceleration`)
// y desliza al personaje a lo largo de paredes y suelos en el step de física.
vel.x = dir.x * 220.0;
// Nota: dt no se usa aquí; el propio plugin integra la velocidad en FixedUpdate.
let _ = time.delta_secs();
}
**MoveAndSlide**: el character controller nativo de Avian 0.7 (en `avian2d::character_controller::move_and_slide`). Toma la velocidad del cuerpo y "desliza" al personaje a lo largo de superficies en vez de atascarse. **NO existe** en Avian ningún `KinematicCharacterController::move_by(...)` con `CharacterControllerConfig`: esa API es de `bevy_rapier2d`.
Hay dos formas de mover un RigidBody::Dynamic:
linvel.x = 220.0. Tú decides exactamente la velocidad cada frame.apply_force(force * dt). Le dices al motor "empújame", él integra.Para el jugador: SIEMPRE por velocidad. Las fuerzas son para objetos físicos "tontos" (cajas que se empujan, vehículos).
// ❌ MAL: usar fuerza para el jugador
commands.spawn((
RigidBody::Dynamic,
// apply_force es un método de componentes externos; spawn-arlo así no funciona.
// El jugador se desliza 3 frames hasta coger velocidad.
));
// ✅ BIEN: usar velocidad para el jugador
commands.spawn((
RigidBody::Dynamic,
LinearVelocity::new(Vec2::new(220.0, 0.0)), // respuesta inmediata
));
Por defecto, Bevy corre tus sistemas en Update a la velocidad de frames de la pantalla (60 FPS, 144 FPS, lo que sea). Para la mayoría de sistemas esto está bien. Pero para física determinista y gameplay que no debe depender del framerate, necesitas un fixed timestep: tu física corre a 60 FPS fijos, aunque el render vaya a 144.
//! cap-17 — sección 17.10.1: fixed timestep.
use bevy::prelude::*;
use avian2d::prelude::*;
// Bevy trae FixedUpdate por defecto. Configura el timestep:
app.insert_resource(Time::<Fixed>::from_seconds(1.0 / 60.0)); // 60 Hz físico.
// Sistemas de física en FixedUpdate, NO en Update.
// (Avian ya engancha su schedule interno a FixedUpdate por defecto.)
app.add_systems(FixedUpdate, (
physics_step,
apply_gravity,
update_positions,
));
Cuándo SÍ usar fixed timestep:
Cuándo NO usarlo:
Update (siguen al frame).Update (la UI no puede ir a tirones).Update (mezcladores asumen tiempo real).El truco del interpolado: si tu física corre a 60 Hz y el render a 144 Hz, la entidad "salta" entre frames físicos. Para suavizar, interpola la posición visual entre el frame físico actual y el anterior:
//! cap-17 — sección 17.10.1: interpolación.
fn interpolate_visual(
time: Res<Time>,
fixed_time: Res<Time<Fixed>>,
mut query: Query<(&Transform, &mut VisualTransform)>,
) {
let alpha = fixed_time.overstep_fraction();
for (physics_xform, mut visual_xform) in &mut query {
visual_xform.translation = physics_xform.translation.lerp(
physics_xform.translation + physics_xform.last_velocity * time.delta_secs(),
alpha,
);
}
}
El alpha es la fracción del frame fijo actual completada (0.0 a 1.0). Lo multiplicas por la diferencia entre el frame actual y el siguiente, y obtienes una posición visual suave.
Regla de oro para juegos 2D con física: fixed timestep a 60 Hz + interpolación. Es la receta que usan Celeste, Hollow Knight, y el 90% de los platformers de calidad.
*El motor de física te da propuestas (colisiones, fuerzas, posiciones). Tu código es quien dispone qué hacer con ellas.*
Tres reglas de oro:
// ❌ MAL: dejar que el motor decida qué pasa cuando el jugador toca un enemigo.
fn enemy_contact(mut events: EventReader<CollisionStart>) {
for ev in events.read() {
// daño automático, sin contemplaciones
damage_player(ev);
}
}
// ✅ BIEN: el motor PROPOSE, tú DISPONES.
fn enemy_contact(
mut events: EventReader<CollisionStart>,
player_query: Query<Entity, With<Player>>,
enemy_query: Query<Entity, With<Enemy>>,
mut player_state: Query<&mut PlayerState>,
) {
for ev in events.read() {
// CollisionStart expone entity1 / entity2 (campos con nombre).
let player = match (player_query.get(ev.entity1), player_query.get(ev.entity2)) {
(Ok(p), _) | (_, Ok(p)) => p,
_ => continue,
};
let other = if ev.entity1 == player { ev.entity2 } else { ev.entity1 };
if enemy_query.get(other).is_ok() {
let mut state = player_state.get_mut(player).unwrap();
// Propongo: el jugador y el enemigo chocan.
// Dispongo: si está invulnerable, ignoro; si está atacando, hago counter;
// si no, recibe daño.
if !state.invulnerable {
state.hp -= 10;
state.invulnerable = true;
}
}
}
}
SpatialQuery (system param) para hacer raycasts, shapecasts, proyección de puntos y tests de intersección sin generar eventos. Perfecto para "el enemigo mira si tiene suelo delante".use avian2d::prelude::*;
use avian2d::spatial_query::SpatialQueryFilter;
// `point_query` proyecta un punto sobre el collider más cercano y te devuelve
// la entidad, el punto proyectado y si el punto está dentro del collider.
// El segundo argumento es un `SpatialQueryFilter` (máscaras de capa),
// NO un bool ni un slice vacío.
fn enemy_ground_check(
spatial: SpatialQuery,
mut query: Query<&Transform, With<Enemy>>,
) {
for t in &query {
let probe = t.translation.truncate() + Vec2::new(0.0, -25.0);
let hit = spatial.point_query(probe, SpatialQueryFilter::default());
// hit.is_some() → hay suelo justo debajo
}
}
**SpatialQueryFilter**: el filtro de capas para queries espaciales en Avian (NO es el `QueryFilter` de Bevy ECS). Se construye con `SpatialQueryFilter::default()` (todo) o `SpatialQueryFilter::from_mask(...)` para limitar capas.
La elección de motor no es trivial: cambiar a mitad de proyecto cuesta 1–2 semanas de refactor. Así que merece la pena pensarlo bien. Lo que NO vamos a hacer es darte una tabla de FPS inventada: los benchmarks de motores de física dependen brutalmente del hardware, del tipo de escena, del número de bodies dinámicos vs estáticos, del tamaño del tilemap y de cómo escribas tus sistemas. Cualquier número "Avian 280 FPS, Rapier 240 FPS" sin metodología reproducible es ruido.
En su lugar, esto es lo que sí podemos decir con seguridad:
Avian gana en:
Rapier gana en:
KinematicCharacterController con move_by y CharacterControllerConfig es más rico que el MoveAndSlide actual de Avian.Aclaración importante sobre Rapier: Rapier no es un wrapper sobre una lib C++. Rapier es la librería de Dimforge, escrita en Rust puro por Sébastien Crozet (el mismo autor de nalgebra) y colaboradores. Lo que hace bevy_rapier2d es tender un puente ECS entre Bevy y esa librería Rust. Avian y Rapier son ambos Rust puro; la diferencia es arquitectura (ECS-nativo vs puente sobre lib externa), no lenguaje.
Cuándo elegir Avian: 2D en general, tilemaps grandes, juegos móviles, equipos que valoran API ECS pura.
Cuándo elegir Rapier: 3D serio, simulación pura de muchos cuerpos dinámicos, o si tu equipo ya lo conoce.
Migración entre motores: dolorosa pero posible. Las APIs son similares en espíritu (RigidBody, Collider, joints, queries), pero las firmas exactas cambian. Si empiezas un proyecto en 2026 y no tienes preferencia, empieza con Avian para 2D y Rapier para 3D.
Cuándo revisar esta comparativa: las versiones evolucionan rápido (Avian 0.7 hoy, 0.8 mañana; Rapier 0.34 hoy). Antes de decidir para un proyecto serio, escribe tu propio benchmark con TU hardware y TU caso de uso: 200 cuerpos dinámicos en una escena tipo tu juego, midiendo FPS y frame time. Ese número sí vale; cualquier tabla genérica que leas por internet (incluida la que teníamos antes en este capítulo) probablemente sea basura.
PhysicsPlugins (PLURAL), no PhysicsPlugin.Collider::rectangle, circle, capsule, convex_hull (todos built-in en Avian).CollisionStart (singular) con campos con nombre entity1 / entity2.CollisionLayers::new(memberships, filters) + derive PhysicsLayer.MoveAndSlide en avian2d::character_controller::move_and_slide. KinematicCharacterController es de bevy_rapier2d, no de Avian.time.delta_secs(), no delta_seconds().En el capítulo 18 vamos a oír el juego. Música adaptativa con MusicState, efectos espaciales 2D, mixers con bevy_kira_audio y el audio nativo ECS-first de Bevy 0.19 (AudioPlayer + PlaybackSettings). Aprenderemos que el audio reactivo es la mitad de la inmersión y por qué Crypt of the NecroDancer sigue enganchando a gente 10 años después de su lanzamiento.