Capítulo 25. Networking ligero con bevy_replicon

CAP 25 · Bevy 0.18/0.19
*"En el multijugador, la red es el jugador más tonto de la sala.

Siempre miente, siempre llega tarde, y a veces se inventa cosas que

no pasaron."*

Glenn Fiedler, autor del blog "Gaffer on Games", antiguo

programador de red en Sony (Starhawk, 2012) e **Irrational

Games (BioShock Infinite**, 2013)

Si has llegado hasta aquí pensando "mi juego es 2D, ¿para qué networking?", respóndete: ¿quieres co-op local? ¿multiplayer online? ¿leaderboards? ¿replay sharing? Todas esas features necesitan red. Y la red, como dijo Fiedler, miente. Vamos a aprender a no creernos sus mentiras.

Networking — en el contexto de un juego, la infraestructura que permite
a varios procesos (clientes) comunicarse con un proceso central
(servidor) para jugar juntos.

Autoridad — la entidad (servidor o cliente) que tiene la "verdad" sobre
el estado de algo. Los demás reciben updates y deben aceptarlas.

Replicación — proceso de copiar el estado del servidor a los clientes.

Predicción — técnica por la que el cliente "adivina" el resultado de
sus propias acciones sin esperar al servidor, para sentirse responsivo.

Reconciliación — cuando la predicción del cliente resulta errónea, el
servidor le envía la verdad y el cliente "rebobina" para corregir.

25.1 La mentira fundamental

Cuando un jugador aprieta "saltar" en su teclado:

Si tu juego espera 200 ms para saltar, el jugador siente que el juego va con dos cafés encima. La solución clásica es predicción: el cliente ejecuta el salto inmediatamente y después se reconcilia con el servidor si hay discrepancia.

📚 La técnica de "client-side prediction with server reconciliation" fue
formalizada por **Yahn Bernier** en el Valve Developer Community wiki
alrededor de **2001**, basándose en el trabajo previo del equipo de
**QuakeWorld** (**id Software**, 1996).

25.2 bevy_replicon vs lightyear: elijas lo que elijas

Hay dos opciones maduras en el ecosistema Bevy a día de hoy (Bevy 0.18/0.19):

CrateFilosofíaCuándo usarlo
bevy_repliconReplicación autoritativa del servidor, simple, sin predicción.Co-op, RPGs, juegos donde la responsividad importa menos que la consistencia.
lightyearReplicación + predicción + interpolación + rollback.Shooters, fighters, cualquier género donde la responsividad es crítica.
bevy_replicon — ahora bajo la org **simgine** en GitHub
(github.com/simgine/bevy_replicon). Versión 0.41.1 para Bevy 0.19.
Inspirado en Mirror Networking de Unity. Backends: renet (UDP),
bevy_quinnet (QUIC).

lightyear — crate de **cBournhonesque** y **Metaphorical**,
inspirado en **GGPO**. Versión 0.28.0 para Bevy 0.19. Más complejo
pero cubre todo el pipeline de netcode: predicción, interpolación,
rollback.

25.3 bevy_replicon en acción (0.19)

Una nota importante sobre el repositorio: bevy_replicon ahora vive en github.com/simgine/bevy_replicon (la org simgine aloja el proyecto; el viejo namespace UkoeHB redirige). Para 0.19, la versión publicada en crates.io es 0.41.1 (junio 2026). Los backends habituales son bevy_replicon_renet 0.17 (sobre renet) y bevy_replicon_quinnet 0.20 (sobre bevy_quinnet, con QUIC).

[dependencies]
bevy = "0.19"
bevy_replicon = "0.41"             # compatible con Bevy 0.19 (jun 2026)
bevy_replicon_renet = "0.17"       # backend sobre renet (UDP fiable)
# alternativa:
# bevy_replicon_quinnet = "0.20"   # backend sobre bevy_quinnet (QUIC)
📚 Repo oficial (julio 2026): **github.com/simgine/bevy_replicon**.
Antes vivía bajo el usuario `UkoeHB`; la migración a la org `simgine`
es para agrupar el ecosistema de replicación. Si ves tutoriales viejos
apuntando a `UkoeHB/bevy_replicon`, funcionan (redirige), pero el
canónico es el de simgine. Backends: `bevy_replicon_renet` 0.17 y
`bevy_replicon_quinnet` 0.20 al día de hoy.
use bevy::prelude::*;
use bevy_replicon::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Component, Serialize, Deserialize, Reflect, Clone, Copy)]
#[reflect(Component)]
struct PlayerPosition {
    x: f32,
    y: f32,
}

// Marcamos qué se replica
#[derive(Component, Serialize, Deserialize, Reflect)]
#[reflect(Component)]
struct Replicated;

// Un mensaje cliente -> servidor
#[derive(Event, Serialize, Deserialize, Debug, Clone)]
enum ClientMessage {
    Move { dx: f32, dy: f32 },
}

// Un mensaje servidor -> cliente
#[derive(Event, Serialize, Deserialize, Debug, Clone)]
enum ServerMessage {
    PlayerMoved { entity: Entity, x: f32, y: f32 },
}

fn main() {
    App::new()
        .add_plugins((
            DefaultPlugins,
            RepliconPlugins,
        ))
        .add_client_event::<ClientMessage>(Channel::Ordered)
        .add_server_event::<ServerMessage>(Channel::Ordered)
        .add_systems(Startup, setup)
        .add_systems(Update, (read_inputs, handle_movement))
        .run();
}

Autoridad

bevy_replicon usa un modelo cliente-servidor autoritativo. El servidor es la verdad, los clientes reciben las posiciones de las otras entidades e interpolan.

// Nota: el componente que marca "replica esto" en bevy_replicon es
// `Replicated` (lo añades en el SERVIDOR). En los CLIENTES, la
// entidad spawneada por la replicación lleva el marker `Remote`, que
// te sirve para diferenciar "esta entidad vino del servidor, yo no la
// controlo". 

fn setup(mut commands: Commands) {
    // Servidor: spawn del jugador autoritativo. El componente
    // `Replicated` marca "este componente se replica a los clientes".
    commands.spawn((
        Replicated,
        PlayerPosition { x: 0.0, y: 0.0 },
    ));
}

fn read_inputs(
    mut move_events: EventWriter<ClientMessage>,
    keys: Res<ButtonInput<KeyCode>>,
) {
    let dx = (keys.pressed(KeyCode::ArrowRight) as i32
            - keys.pressed(KeyCode::ArrowLeft)  as i32) as f32;
    let dy = (keys.pressed(KeyCode::ArrowUp)    as i32
            - keys.pressed(KeyCode::ArrowDown)  as i32) as f32;
    if dx != 0.0 || dy != 0.0 {
        move_events.write(ClientMessage::Move { dx, dy });
    }
}

// En el servidor:
fn handle_movement(
    mut events: EventReader<FromClient<ClientMessage>>,
    mut players: Query<&mut PlayerPosition, With<Replicated>>,
) {
    for FromClient { client_id, event } in events.read() {
        let Ok(mut pos) = players.single_mut() else { continue; };
        pos.x += event.dx * 2.0;
        pos.y += event.dy * 2.0;
        // Bevy replicará automáticamente el cambio a los clientes.
    }
}
❌ Usar `ReplicationTarget::default()` como si fuera el marker de
replicación. Esa API no es la canónica de bevy_replicon. El marker
correcto en el servidor es `Replicated` (un componente insertado en
las entidades que quieres replicar). En el cliente, las réplicas
reciben un marker `Remote` (o equivalente en tu versión; verifica en
la doc de 0.41.1). No inventes un `ReplicatedClient`.
✅ Lee siempre `docs.rs/bevy_replicon/0.41.1` antes de copiar código:
la API de bevy_replicon cambia significativamente entre minors.

25.4 lightyear: cuando quieres predicción

A julio de 2026, lightyear está publicado en la versión 0.28.0, compatible con Bevy 0.19. Es un crate bastante más complejo que bevy_replicon: te da predicción, interpolación y rollback listos, a cambio de una curva de aprendizaje más alta y un modelo mental diferente (ticks, buffers de inputs, checksums). El ejemplo siguiente es orientativo: la API concreta (nombres de traits, métodos) cambia entre minors; consulta docs.rs/lightyear/0.28.0 y los ejemplos del repo antes de copiar nada a producción.

[dependencies]
bevy = "0.19"
lightyear = "0.28"   # compatible con Bevy 0.19 (jun 2026)

lightyear te obliga a marcar explícitamente qué componentes son:

use lightyear::prelude::*;

// PSEUDO-API: los nombres exactos (trait de configuración,
// método para el mode, builder) cambian entre versiones de lightyear.
// 
#[derive(Component, Serialize, Deserialize, Reflect, Clone, Copy)]
#[reflect(Component)]
struct PlayerPosition(Vec2);

// Patrón orientativo: configurar la replicación de un componente.
// En lightyear 0.28 la API se basa en `LinkContext`, `Channel` y
// registros en el `Plugin`, en vez de un trait sobre el componente.
// Mira el ejemplo `bevy_examples` del repo para tu versión exacta.

#[derive(Component, Serialize, Deserialize, Reflect, Clone, Copy)]
#[reflect(Component)]
struct Velocity(Vec2);
❌ Poner TODO como `Predicted`. Predicción significa que el cliente
ejecuta la simulación antes de tener confirmación. Si lo haces con la
IA enemiga, tendrás mil entidades ejecutando código que no debería.
✅ Predice **solo lo que el jugador controla directamente**. El resto
debería ser Interpolated o simplemente Replicated sin predicción.

25.5 State sync vs input sync

Hay dos grandes filosofías en netcode de juegos 2D:

State sync (estilo Quake, Overwatch, Valorant)

Input sync (estilo GGPO, fighters, Rocket League)

// Pseudo-código de input sync (estilo lightyear)

#[derive(Serialize, Deserialize, Component, InputPayload)]
struct PlayerInput {
    move_dir: Vec2,
    jump: bool,
    attack: bool,
    tick: u16,
}

fn gather_input(keys: Res<ButtonInput<KeyCode>>) -> PlayerInput {
    PlayerInput {
        move_dir: Vec2::new(
            (keys.pressed(KeyCode::KeyD) as i32 - keys.pressed(KeyCode::KeyA) as i32) as f32,
            (keys.pressed(KeyCode::KeyW) as i32 - keys.pressed(KeyCode::KeyS) as i32) as f32,
        ),
        jump: keys.pressed(KeyCode::Space),
        attack: keys.pressed(KeyCode::KeyJ),
        tick: current_tick(),
    }
}
📚 **GGPO** (Good Game Peace Out) fue creado por **Tony Cannon**
(**Capcom**, **Street Fighter III: 3rd Strike** online) en **2006**.
Su técnica de rollback netcode se convirtió en el estándar de los
fighters. **Skullgirls** (**Lab Zero**, 2012) y más tarde **Guilty
Gear Strive** (**Arc System Works**, 2021) lo popularizaron.

25.6 Rollback intro (sin meternos en el fondo del GGPO)

El rollback en 3 frases:

  1. El cliente recibe un input del servidor con tick 30.
  2. El cliente ya ha avanzado al tick 35 localmente (predicción).
  3. El cliente rebobina al tick 30, aplica el input oficial, y vuelve a simular del 30 al 35 con la nueva info.
// Pseudo-sistema de rollback
fn rollback_loop(
    mut history: ResMut<WorldHistory>,
    confirmed_input: PlayerInput,
    current_tick: u16,
) {
    let confirmed_tick = confirmed_input.tick;

    if confirmed_tick < current_tick {
        // Rebobina el mundo al estado en confirmed_tick
        history.restore(confirmed_tick);

        // Aplica el input oficial
        apply_input(confirmed_input);

        // Re-simula desde confirmed_tick hasta current_tick
        for tick in confirmed_tick..=current_tick {
            simulate_one_tick(tick, history.input_at(tick));
            history.save(tick);
        }
    }
}

lightyear implementa esto por ti con su Predicted mode. No tienes que escribir el loop a mano (bendito seas, cBournhonesque).

25.7 Tickrate, lag compensation y la maldición de los 60 Hz

Tickrate = número de simulaciones por segundo. 60 Hz es el estándar, pero muchos juegos competitivos usan 128 Hz (Valorant, CS2). A más tickrate, más responsividad pero más CPU y ancho de banda.

Lag compensation: el servidor retrasa su vista del mundo lo suficiente para que, cuando un jugador dispara, el servidor vea al otro jugador donde estaba hace 100 ms, no donde está ahora (porque con 100 ms de ping, el cliente no habría visto el movimiento todavía).

// Sistema simplificado de lag compensation
fn fire_bullet(
    commands: &mut Commands,
    shooter_pos: Vec2,
    target_pos_history: &History<Vec2>,
    server_now_tick: u16,
    shooter_ping_ms: u32,
) {
    // Cuántos ticks atrás estaba el mundo "para el shooter"
    let compensation_ticks = (shooter_ping_ms as f32 / 1000.0 * TICKRATE as f32) as u16;
    let target_pos = target_pos_history
        .get(server_now_tick - compensation_ticks)
        .copied()
        .unwrap_or_default();

    if (shooter_pos - target_pos).length() < HIT_RADIUS {
        // Hit!
    }
}
📚 El concepto de lag compensation se formalizó en **Valve** con
**Counter-Strike** alrededor de **2000-2001**. Los foros están
llenos de threads de 2002 discutiendo si era "justo". Spoiler: lo era.

🎯 25.8 Patrón del capítulo: "la red miente, tu código lo sabe"

**En multijugador, asume que cada mensaje puede llegar tarde, fuera

de orden, duplicado o perdido. Tu código debe poder tolerarlo todo

sin romperse, congelarse ni desincronizarse. Si tu simulación asume

"los inputs llegan en orden", tienes un bug esperando a producción.**

Las consecuencias prácticas:

  1. Idempotencia: un mismo mensaje aplicado dos veces debe producir el mismo resultado. Usa IDs de input, no confíes en "el jugador solo aprieta una vez".
  2. Snapshots, no deltas frágiles: en 2D con pocas entidades, manda el estado completo cada 100 ms. Es más fácil de debuggear que deltas comprimidos.
  3. Distingue causa y efecto: en input sync, los inputs son la verdad. El estado del mundo es consecuencia. Si discrepa, el input gana.
  4. Loggea todo en debug: añade info!("recv {:?} from {}", msg, client_id) en cada mensaje entrante. Cuando alguien reporte "el otro jugador se teletransportó", esos logs valen oro.
❌ Confiar en `Instant::now()` para "cuándo pasó algo" en multiplayer.
Los relojes de los clientes no están sincronizados. Usa **ticks de
servidor** (un u64 que va incrementando) como referencia temporal.
✅ `Instant` solo en cliente. Servidor: ticks. Cliente: ticks relativos
al iniciar la conexión.

Lo que vimos

En el siguiente

Con esto cerramos la parte de patrones y subsistemas. En el capítulo 26 (siguiente bloque) nos meteremos con publicación y profiling: cómo empaquetar tu juego, medir FPS con bevy_diagnostic, detectar memory leaks con bevy_memory_profiler y preparar el juego para Steam. Pero eso es otra historia. Por ahora, ¡a predecir y reconciliar!