— Glenn Fiedler, "Gaffer on Games", 2015. Sigue siendo la mejor referencia sobre el tema.
El cap. 25 cubrió bevy_replicon y la replicación autoritativa. Está bien para juegos donde la latencia no importa (un RPG por turnos, un strategy lento, un MMO). Pero para juegos de acción rápida (peleas, fútbol, bullet hell, cualquier cosa donde 50 ms de latencia se noten), replicar "lo que el servidor dice" se siente horrible. La solución: predicción cliente + rollback.
La idea: el cliente no espera al servidor. Simula lo que va a pasar (predice) y, si el servidor le dice "no, en realidad fue así", retrocede en el tiempo, aplica la corrección, y vuelve a simular (rollback + resimulación). El jugador ve su input reflejado al instante; la corrección es invisible si la latencia es razonable.
Esto se llama netcode de rollback. Lo popularizaron los juegos de pelea online (GGPO, usado en Killer Instinct, Skullgirls, Them's Fightin' Herds) y Rocket League.
Tres modelos de netcode:
1. Lockstep (viejo, rígido): ambos clientes esperan a todos. Lento.
2. Server-authoritative con client prediction (Bevy cap. 25): el cliente predice, el server confirma. Funciona para juegos lentos.
3. Rollback determinista (GGPO-style): ambos clientes simulan lo mismo en paralelo; si difieren, el más reciente hace rollback. Rápido y preciso para juegos de acción.
Este capítulo cubre el #3.
Para que el rollback funcione, ambas máquinas (cliente y servidor) deben simular el mismo mundo con los mismos inputs y obtener el mismo resultado. Si en tu PC el player se mueve 3.2 píxeles y en la mía 3.200001, el rollback falla.
El determinismo en Rust es más fácil que en otros lenguajes porque no hay un JIT. Pero hay trampas:
//! cap-25B — sección 25B.2: fuentes de no-determinismo.
use std::collections::HashMap;
// ❌ HashMap con keys random: cada run tiene un orden distinto.
// Insertar las mismas 5 keys produce diferente iteración.
let mut m: HashMap<u32, String> = HashMap::new();
// ✅ Bevy tiene `BTreeMap` o `bevy::utils::HashMap` con hash determinista.
// O usá un índice secuencial.
Las cinco fuentes clásicas de no-determinismo en juegos:
HashMap: el HashMap de stdlib tiene un hash aleatorio por proceso. Solución: bevy::utils::HashMap (hash determinista) o BTreeMap (ordenado por clave).(a + b) + c != a + (b + c) con floats. Solución: usa f32::EPSILON o redondeos explícitos si tu lógica depende de igualdad exacta.std::time::SystemTime::now() da distinto cada llamada. Solución: usa siempre Time<Virtual> (cap. 7) o un Time determinista.rand::random::<f32>() da un valor distinto cada vez. Solución: siembrá el PRNG con un valor determinista (un tick del juego, un hash de la partida).Schedule ejecuta sistemas en orden variable, los inputs se aplican en orden variable. Solución: schedules explícitos, before/after claros.FixedUpdate y Time<Fixed>: el reloj deterministaLa base del rollback es un reloj que avanza a un ritmo fijo y predecible. Bevy lo trae de fábrica:
//! cap-25B — sección 25B.3: configurar FixedUpdate.
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
// 60 Hz fijo. Default ya está en 60, pero explícito.
.insert_resource(Time::<Fixed>::from_hz(60.0))
.add_systems(FixedUpdate, my_simulation_systems)
.run();
}
FixedUpdate corre a 60 Hz (o lo que pongas), independientemente del framerate del juego. Cada tick, avanzás la simulación un paso. Para el rollback, "volver 5 ticks atrás" es trivial: tenés 5 steps guardados.
Caveat: el
Updateschedule es variable; elFixedUpdatees determinista. Toda la lógica de gameplay (movimiento, IA, física) debe correr enFixedUpdate. La presentación (animaciones, interpolación, UI) corre enUpdate.
Para hacer rollback, necesitás guardar el estado del mundo en cada tick. La forma simple: snapshot del World cada N ticks. La forma pro: solo guardar lo que cambia (inputs, posiciones).
//! cap-25B — sección 25B.4: historial de inputs por tick.
use bevy::prelude::*;
#[derive(Resource, Default)]
struct InputHistory {
ticks: std::collections::VecDeque<TickInputs>,
max_ticks: usize, // cuántos ticks guardar (latencia máxima esperada).
}
#[derive(Clone, Debug, Default)]
struct TickInputs {
tick: u64,
local_inputs: u32, // bitmask de botones locales.
remote_inputs: Option<u32>, // si llegó del otro cliente.
}
impl InputHistory {
fn record_local(&mut self, tick: u64, inputs: u32) {
self.ticks.push_back(TickInputs { tick, local_inputs: inputs, remote_inputs: None });
if self.ticks.len() > self.max_ticks {
self.ticks.pop_front();
}
}
fn record_remote(&mut self, tick: u64, inputs: u32) {
// Buscar el tick en el historial y actualizar.
for ti in self.ticks.iter_mut() {
if ti.tick == tick {
ti.remote_inputs = Some(inputs);
return;
}
}
}
}
La estructura: cada cliente tiene un historial de los últimos N ticks (típico: N = 8-15, suficiente para latencias de 100-250 ms). Cada tick tiene: inputs locales, inputs remotos (si llegaron), y un snapshot del mundo.
Pseudocódigo del loop de simulación con rollback:
Cada tick (FixedUpdate):
1. Recibir inputs del otro cliente (si llegaron).
2. Si un input remoto "corrige" uno que ya predeciste:
- Restaurar el snapshot del tick T.
- Re-simular desde T hasta el tick actual, usando los inputs correctos.
3. Avanzar la simulación un tick con los inputs actuales.
4. Guardar snapshot del nuevo estado.
5. Enviar mis inputs al otro cliente.
La parte difícil es el paso 2: la re-simulación. Para que sea rápida, la simulación debe ser determinista y barata. Si cada tick toma 5 ms re-simular, y tenés que re-simular 8 ticks, son 40 ms: visible. Si la simulación es 0.5 ms, 8 ticks son 4 ms: invisible.
Tres optimizaciones comunes:
u64 hash. Si difiere, hacés rollback con el snapshot completo. Si coincide, no hacés nada.Implementar rollback en Bevy sin crate externo es posible pero doloroso. Los pasos:
//! cap-25B — sección 25B.6: skeleton de rollback.
use bevy::prelude::*;
use bevy::ecs::world::World;
use std::collections::VecDeque;
#[derive(Resource)]
struct RollbackState {
current_tick: u64,
snapshots: VecDeque<World>, // ⚠️ no recomendado, ver caveat.
inputs: VecDeque<TickInputs>,
is_rolling_back: bool,
}
fn simulation_step(
mut state: ResMut<RollbackState>,
mut commands: Commands,
time: Res<Time<Fixed>>,
) {
// Si estamos haciendo rollback, no avanzamos tick.
if state.is_rolling_back { return; }
// 1. Snapshot antes de avanzar.
state.snapshots.push_back(state.current_world_clone());
if state.snapshots.len() > 8 {
state.snapshots.pop_front();
}
// 2. Avanzar la simulación.
state.current_tick += 1;
// ... tu lógica de simulación ...
}
Caveat: World::clone() existe pero es caro. Para un rollback serio, usá un crate que sepa qué snapshotear. O usá bevy::scene::DynamicScene (cap. 12) para serializar/deserializar el World selectivamente.
bevy_rewind: el crate que te lo da hecho (casi)bevy_rewind (v0.0.1, marzo 2025) es el primer intento serio de crate Bevy para rollback. Inspirado en la charla de GDC sobre Rocket League, construido sobre bevy_replicon.
//! cap-25B — sección 25B.7: Cargo.toml (v4.0 actualizado a 0.19).
[dependencies]
bevy = "0.19"
# bevy_replicon 0.41.1 es la versión publicada compatible con Bevy 0.19
# (junio 2026). Repo: github.com/simgine/bevy_replicon.
bevy_replicon = "0.41"
# bevy_rewind sigue en 0.0.1 (muy temprano, marzo 2025). API inestable.
bevy_rewind = "0.0.1"
Nota v4.0 — fix de versión: la versión original de este cap (v3.0) decía
bevy_replicon = "0.33", que era válida para Bevy 0.16/0.17. En julio 2026,bevy_repliconestá en 0.41.1, alineado con Bevy 0.19. Verifica la compat en el repo <https://github.com/simgine/bevy_replicon> antes de fijar versión: el ritmo de releases de replicon no siempre va al día de Bevy el mismo día.
//! cap-25B — sección 25B.7: setup básico.
// ⚠️ API de bevy_rewind 0.0.1 NO verificada en profundidad. Trata
// este ejemplo como orientativo y léelo contra el repo antes de
// copiarlo.
use bevy::prelude::*;
use bevy_rewind::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(RewindPlugin::default())
.add_plugins(bevy_replicon::RepliconPlugins)
.run();
}
// Marcar componentes como "rollback-able":
#[derive(Component, Reflect, Clone, Debug)]
#[reflect(Component)]
struct PlayerPosition { x: f32, y: f32 }
//
fn register_components(mut registry: ResMut<ComponentRegistry>) {
registry.register_rollback::<PlayerPosition>();
}
Caveat importante: bevy_rewind está en 0.0.1, marzo 2025. Es muy temprano. La API puede cambiar. Antes de usarlo en producción, leé el código y los issues abiertos.
Alternativas en el ecosistema:
bevy_replicon_snap: snapshot interpolation sobre bevy_replicon. No es rollback puro, pero útil para juegos donde los clientes no necesitan predecir.bevy_ggrs 0.22.0: P2P con GGRS. Compatible con Bevy 0.19. Más maduro pero menos integrado con el ecosistema reciente.bevy_timewarp: predict/rollback, también integrado con replicon. Otra opción.Para 2026, el espacio está madurando pero no estabilizado. Si tu juego necesita rollback serio hoy, probá bevy_rewind o bevy_timewarp y prepárate para el refactor.
Para entender mejor, miremos un sistema de movimiento con predicción:
//! cap-25B — sección 25B.8: movimiento con predicción.
use bevy::prelude::*;
#[derive(Component, Default, Clone, Copy)]
struct Velocity { x: f32, y: f32 }
#[derive(Component, Default, Clone, Copy)]
struct Position { x: f32, y: f32 }
#[derive(Bundle, Default, Clone, Copy)]
struct Player { position: Position, velocity: Velocity }
fn predict_movement(
mut q: Query<(&mut Position, &mut Velocity), With<Player>>,
inputs: Res<CurrentInputs>, // inputs locales + remotos confirmados.
time: Res<Time<Fixed>>,
) {
let dt = time.delta_secs();
for (mut pos, mut vel) in q.iter_mut() {
// Aplicar inputs.
if inputs.local.left { vel.x -= 100.0 * dt; }
if inputs.local.right { vel.x += 100.0 * dt; }
// Gravedad.
vel.y -= 200.0 * dt;
// Integrar.
pos.x += vel.x * dt;
pos.y += vel.y * dt;
}
}
Lo que distingue "predicción" de "simulación normal" es: en predicción, usás los inputs locales ANTES de que el servidor los confirme. Si el servidor después dice "no, te moviste 0.5 píxeles menos", el sistema de rollback reaplica la simulación con los inputs corregidos.
Sin predicción, cada input espera ~50-100 ms al servidor. Con predicción, el input se refleja al instante, y la corrección es invisible (1-2 frames) si la latencia es razonable.
Un test crítico: que tu simulación sea determinista. Corréla dos veces y comparás el estado.
//! cap-25B — sección 25B.9: test de determinismo.
#[test]
fn test_simulation_is_deterministic() {
let run_1 = simulate(Some(42)); // seed 42.
let run_2 = simulate(Some(42)); // misma seed.
assert_eq!(run_1, run_2, "La simulación debe ser determinista con misma seed");
let run_3 = simulate(Some(43)); // seed distinta.
assert_ne!(run_1, run_3, "Distintas seeds deben dar distintos resultados (con misma lógica)");
}
fn simulate(seed: Option<u64>) -> SimulationState {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.insert_resource(Seed(seed.unwrap_or(0)));
// Setup del mundo.
for _ in 0..100 { app.update(); }
// Extraer estado.
extract_state(&app.world())
}
Si este test pasa, tu simulación es al menos determinista en su propio entorno. El determinismo entre dos PCs distintos es más difícil (drivers de GPU, optimizaciones de compilador), pero si la lógica está limpia, hay esperanza.
Determinismo: la propiedad de que "mismos inputs → mismo output".
Netcode: lógica de networking de un juego.
Server-authoritative: el server tiene la verdad. El cliente muestra lo que el server le dice.
Client prediction: el cliente simula antes de que el server confirme.
Rollback: volver a un estado anterior y re-simular con inputs correctos.
Resimulation: re-correr la simulación desde un punto anterior.
Lockstep: protocolo donde todos los clientes esperan a todos.
Snapshot: estado del mundo en un tick dado.
Input history: registro de los inputs de los últimos N ticks.
Checksum: hash corto de un estado. Sirve para "saber si cambió" sin comparar todo.
GGPO: librería de rollback popular, base de muchos juegos de pelea online.
Rocket League: caso de estudio de rollback (GDC talk).
FixedUpdate: schedule de Bevy que corre a frecuencia fija. Base del determinismo.
Time<Fixed>: resource que lleva el delta fijo (típico 60 Hz).
PRNG: Pseudo-Random Number Generator. Si no lo sembrás, no es determinista.
Bevy rewind: crate de Bevy para rollback. Temprano pero prometedor.
bevy_replicon_snap: crate para snapshot interpolation (no rollback puro).
bevy_timewarp: crate de predict/rollback sobre replicon.
bevy_ggrs: P2P con GGRS. Más viejo pero más maduro.
Age of Empires (Ensemble Studios, 1997) — usaba lockstep con un truco: cada cliente tenía el mismo RNG sembrado con la misma semilla, y los inputs venían de todos. Determinista a la fuerza. La comunidad descubrió que un jugador podía desincronizar a todos mandando un input "tóxico" (un click en un lugar que causaba una excepción). Bug famoso.
Quake 3 (id Software, 1999) — popularizó el server-authoritative con client prediction. El paper de John Carmack sobre el tema (2001) sigue siendo lectura obligatoria.
GGPO (Tony Cannon, 2006) — la librería que popularizó el rollback para juegos de pelea. Killer Instinct (2013) fue el primer AAA en adoptarlo a escala. El código de GGPO es abierto; podés leerlo.
Rocket League (Psyonix, 2015) — demostró que el rollback funciona para juegos con física 3D rápida. La GDC talk de 2018 sobre su netcode es la mejor referencia moderna.
bevy_rewind (2025) — el primer crate serio de rollback para Bevy. Construido sobre la infraestructura de `bevy_replicon`. v0.0.1 al momento de escribir; API en evolución.
Overwatch (Blizzard, 2016) — usa un híbrido: server-authoritative con predicción, más interpolación de movimientos de otros jugadores. Compromiso entre simplicidad y feel.
Bevy `FixedUpdate` (0.6+) — el schedule que hace posible el determinismo. Antes de él, los sistemas corrían en orden variable.
❌ Usar `std::collections::HashMap` en tu lógica de gameplay.
✅ `bevy::utils::HashMap` (hash determinista) o `BTreeMap`.
💡 Por qué: el hash de `std::collections::HashMap` se randomiza por proceso para evitar ataques de hash-flooding. Esa misma randomization rompe el determinismo.
❌ Mover lógica de gameplay a `Update` (frame variable) y esperar rollback.
✅ Toda la lógica de simulación en `FixedUpdate`. Solo presentación en `Update`.
💡 Por qué: `Update` corre a framerate variable; los snapshots no se corresponden a los mismos "puntos del tiempo" entre dos clientes. `FixedUpdate` corre a ritmo fijo, sincronizable.
❌ Asumir que `bevy_rewind` 0.0.1 es "production-ready" porque está en el repo de un mantenedor respetado.
✅ Leer el código, leer los issues, hacer un prototipo pequeño antes de comprometerse.
💡 Por qué: 0.0.1 significa "esto compila y demuestra el concepto", no "esto está listo para producción".
❌ Comparar floats con `==` en tu lógica determinista.
✅ `f32::EPSILON`-based: `(a - b).abs() < f32::EPSILON`.
💡 Por qué: operaciones de punto flotante pueden divergir entre CPUs. Si tu lógica depende de "este valor es exactamente 0", vas a ver desincronizaciones.
Problema: querés un juego online rápido. Empezás a meter networking, te das cuenta de que tu simulación no es determinista, y ahora tenés que reescribir todo.
Solución: desde el día uno, tratá tu simulación como si fuera a correr en dos máquinas. La regla:
- HashMap → bevy::utils::HashMap.
- PRNG → siempre con seed.
- Update → solo presentación; FixedUpdate → simulación.
- f32 → nunca compares con ==; usa epsilon o enteros escalados.
- Clock → Time<Fixed>, nunca SystemTime.
- Inputs → recibilos por un Resource explícito, no leas el teclado directo.
Cuándo sí:
Cuándo no:
FixedUpdate y Time<Fixed> como base del reloj determinista.bevy_rewind, bevy_replicon_snap, bevy_timewarp.Capítulo 26 (intermedio): optimizaciones a fondo. El cap. 26 te enseñó batching, LOD, culling, asset streaming. Ahora vamos a ver el profiling real: Tracy, RenderDoc, GPU timings, cómo encontrar el cuello de botella. Spoiler: "optimiza lo que se mide, no lo que se siente lento".