Capítulo 24. Persistencia, guardado y serialización

CAP 24 · Bevy 0.18/0.19
*"Los jugadores no perdonan que se les borre la partida. Los jugadores

no perdonan nada, en realidad."*

Hideo Kojima, entrevista sobre Metal Gear Solid V: The Phantom Pain (Konami, 2015)

Bienvenido al capítulo donde aprendemos a no ser ese estudio que pierde partidas guardadas. Spoiler: el 90% de los bugs de save/load vienen de guardar el componente equivocado. Vamos al lío.

Persistencia — capacidad de un juego de mantener estado entre sesiones.
En Bevy va desde "guardar el volumen" hasta "serializar 10000 entidades
con sus componentes, hierarchy incluido".

Serialización — proceso de convertir una estructura en memoria a un
formato guardable (JSON, binario, msgpack). En Rust se hace con `serde`.

Deserialización — el revés: de bytes de disco a struct en memoria.
Ahí es donde todo se rompe.

24.1 La regla de oro: datos, no componentes

Antes de escribir una línea de código, grábate esto en la frente con un rotulador indeleble:

Persiste datos (structs planos, serializables). Reconstruye componentes a partir de esos datos en un sistema de carga.

¿Por qué? Porque los componentes de Bevy llevan estado vivo (handles a assets, observers, relaciones a otras entidades) que no se serializa bien o directamente no se debe serializar. Si intentas serde-ar un componente tal cual, vas a acabar con assets fantasma, referencias colgantes y un crash en producción a las 3 de la madrugada del viernes.

❌ Serializar el componente `Handle<TextureAtlas>` directamente.
El path del asset cambia entre versiones, los índices se reorganizan,
y de pronto tienes un cangrejo donde iba un caballero.
✅ Serializar la **clave lógica** del asset (un enum, un id, un string)
y reconstruir el `Handle` al cargar buscando en `Assets<T>`.

24.2 bevy_save — aviso honesto: NO compatible con 0.19

bevy_save es un crate comunitario que durante un tiempo se vendió como "la solución estándar" para persistencia en Bevy. La versión publicada en crates.io a julio de 2026 es la 2.0.1, y su Cargo.toml declara bevy = "^0.16.1". Es decir: no compila contra Bevy 0.17, 0.18 ni 0.19.

⚠️ Aviso de compatibilidad (julio 2026): si estás en Bevy 0.19, bevy_save = "2.0.1" no te sirve. El crate no ha recibido un release alineado con 0.17+. Existen forks en GitHub que lo portan a versiones más nuevas, pero ninguno publicado en crates.io a día de hoy. Las APIs que documentan tutoriales antiguos (Snapshot::from_world, SnapshotSerializer, ReflectSnapshot, .applier("world")) no las des por válidas en 0.19: proceden de APIs que ya cambiaron o dejaron de existir.

# ❌ NO recomendado en Bevy 0.19 (julio 2026). bevy_save 2.0.1 depende
#    de Bevy 0.16.1 y no compila contra 0.17/0.18/0.19.
[dependencies]
bevy_save = "2.0.1"   # <-- SOLO para Bevy 0.16. Incompatible con 0.19.
❌ Copiar tutoriales de bevy_save con APIs tipo `Snapshot::from_world`,
`SnapshotSerializer::new(...)`, `.applier("world").serialize(...)` o
`#[reflect(Snapshot)]`. Esas APIs no son canónicas en 0.19 y varias no
compilan. Para Bevy 0.19, usa `DynamicScene` + `serde` (ver §24.5) o el
plugin oficial `SettingsPlugin` para settings (ver §24.4).

Alternativa recomendada: DynamicScene + serde

Para snapshotear el mundo en Bevy 0.19, la vía soportada por el core es DynamicSceneBuilder (ver §24.5 para el código completo) o, si solo quieres persistir structs de datos (lo recomendado por el patrón del capítulo), serializa tus propios #[derive(Serialize, Deserialize)] con serde_json o rmp-serde. Sin crates externos, sin sorpresas cuando Bevy cambia de versión.

📚 `bevy_save` apareció alrededor de **2023-2024** como respuesta a la
falta de una API oficial de persistencia. Tuvo tracción, pero el ritmo
de Bevy (un release mayor cada ~3 meses) lo dejó atrás. Lección: para
cosas que tocan el World directamente, los crates externos son frágiles.
Las APIs oficiales (`DynamicScene`, `SettingsPlugin`) son más aburridas
pero estables.

24.3 Roll-your-own con serde (a veces es mejor)

A veces bevy_save es overkill. Para settings del juego o perfiles de jugador, basta con un struct serializable a JSON en un archivo settings.json. Cinco líneas, sin plugins.

use serde::{Deserialize, Serialize};
use std::path::Path;

#[derive(Resource, Serialize, Deserialize, Debug)]
pub struct GameSettings {
    pub master_volume: f32,
    pub sfx_volume: f32,
    pub music_volume: f32,
    pub fullscreen: bool,
    pub vsync: bool,
    pub language: String,
}

impl Default for GameSettings {
    fn default() -> Self {
        Self {
            master_volume: 0.8,
            sfx_volume: 0.7,
            music_volume: 0.6,
            fullscreen: false,
            vsync: true,
            language: "es".to_string(),
        }
    }
}

pub struct SettingsPlugin;

impl Plugin for SettingsPlugin {
    fn build(&self, app: &mut App) {
        let settings = load_or_default();
        app.insert_resource(settings)
           .add_systems(Update, autosave_on_change);
    }
}

fn load_or_default() -> GameSettings {
    let path = Path::new("settings.json");
    match std::fs::read_to_string(path) {
        Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
        Err(_) => GameSettings::default(),
    }
}

fn save(settings: Res<GameSettings>) -> std::io::Result<()> {
    let json = serde_json::to_string_pretty(&*settings)?;
    std::fs::write("settings.json", json)
}

fn autosave_on_change(settings: Res<GameSettings>) {
    if settings.is_changed() {
        if let Err(e) = save(settings) {
            error!("No pude guardar settings: {e}");
        }
    }
}
📚 El formato JSON para settings es ubicuo desde los 2000s. **Douglas
Crockford** lo specificationó formalmente alrededor de **2006-2013**
mientras trabajaba en **Yahoo!**. Antes de eso, todo el mundo
usaba XML y el mundo era un lugar más triste.

24.4 App Settings plugin (0.19) — SettingsPlugin oficial

Bevy 0.19 introduce un plugin oficial de App Settings llamado SettingsPlugin. Lo importante: el plugin se encarga solo de cargar y persistir tus structs de configuración a/desde disco (formato RON por defecto). Los efectos colaterales te tocan a ti: si el usuario cambia fullscreen, el plugin guarda el nuevo valor, pero no te cambia el modo de ventana automáticamente; eso lo escribes tú en un sistema que lea Res<GraphicsSettings> y reaccione con ResMut<Windows> o el evento pertinente.

El patrón canónico (verificado en el anuncio de Bevy 0.19):

  1. Creas un SettingsPlugin::new("com.ejemplo.mi-juego") con un reverse-domain name que identifica tu app (lo usa para resolver el path de guardado según plataforma: dirs::config_dir(), etc.).
  2. Derivas SettingsGroup (el derive real de bevy_settings) en cada struct de settings. El atributo #[settings_group(file = "...")] indica el nombre del archivo por grupo.
  3. El plugin carga automáticamente todos los SettingsGroup registrados al arrancar y los inserta en el World como Resource.
  4. Cuando un sistema muta el resource (lo modificas en un sistema de opciones), el plugin serializa y guarda en disco en un punto controlado del schedule.
// crates/app_settings_demo/src/main.rs
// Requiere Bevy 0.19+ (el plugin es nuevo en esta versión).
use bevy::prelude::*;
use bevy::settings::{SettingsPlugin, SettingsGroup};  // path del módulo oficial
use serde::{Deserialize, Serialize};

// Cada struct de configuración deriva `SettingsGroup`. El atributo
// `file` indica el archivo donde se persiste (relativo al directorio
// de la app que resuelve el plugin según el reverse-domain name).
#[derive(Resource, Reflect, SettingsGroup, Serialize, Deserialize, Default, Clone)]
#[settings_group(file = "settings.ron")]
pub struct GraphicsSettings {
    pub fullscreen: bool,
    pub vsync: bool,
    pub resolution_scale: f32,
}

#[derive(Resource, Reflect, SettingsGroup, Serialize, Deserialize, Default, Clone)]
#[settings_group(file = "audio.ron")]
pub struct AudioSettings {
    pub master_volume: f32,
    pub music_volume: f32,
    pub sfx_volume: f32,
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        // `new` recibe un reverse-domain name único para tu app.
        // El plugin lo usa para resolver el directorio de guardado.
        .add_plugins(SettingsPlugin::new("com.ejemplo.mi-juego"))
        // Registras cada grupo. Al arrancar, el plugin carga el .ron
        // (si existe) e inserta el resource en el World. Si no existe,
        // usa `Default::default()`.
        .init_settings::<GraphicsSettings>()
        .init_settings::<AudioSettings>()
        .add_systems(Update, apply_fullscreen_setting)
        .run();
}

// ⚠️ El plugin NO aplica efectos colaterales. Tú escribes sistemas
// que reaccionan a cambios en el resource. Ejemplo: aplicar fullscreen.
fn apply_fullscreen_setting(
    graphics: Res<GraphicsSettings>,
    mut windows: Query<&mut Window>,
) {
    if !graphics.is_changed() { return; }
    for mut win in &mut windows {
        win.mode = if graphics.fullscreen {
            WindowMode::BorderlessFullscreen
        } else {
            WindowMode::Windowed
        };
    }
}
❌ Asumir que `SettingsPlugin` te aplica los efectos colaterales por
magia ("cambió fullscreen, ya me cambia el modo de ventana"). NO. El
plugin persiste y carga; TÚ escribes el sistema que aplica.
❌ Inventar atributos tipo `#[settings(version=2)]` o derives tipo
`AppSettings` / `add_app_settings::()`. La API real usa el derive
`SettingsGroup` y la llamada `.init_settings::()` (o equivalente en
tu versión exacta). 

Versionado y migraciones

Para versionar, la estrategia más segura en 0.19 sigue siendo la de siempre: campo version: u32 en el archivo y migraciones explícitas en código Rust (ver §24.7). El plugin SettingsPlugin no impone un sistema de migraciones propio; te da el hook de carga y el de guardado, y tú decides cómo tratar versiones viejas cuando detectas que el schema cambió. La regla de oro del §24.7 sigue en pie: versiona desde el día uno.

❌ Asumir que `settings.json` existe. El primer arranque, no existe.
El usuario borró el archivo. El antivirus lo bloqueó. Siempre ten un
`unwrap_or_default()` o un `match` con fallback a `Default::default()`.

24.5 Save/Load de entidades con el SceneSerializer

Bevy trae SceneSerializer / SceneDeserializer "de fábrica". Es más crudo que bevy_save pero te da control total y no depende de ningún crate externo.

use bevy::scene::{DynamicScene, DynamicSceneBuilder};

fn save_scene(world: &World) -> Result<String, Box<dyn std::error::Error>> {
    // Construimos un scene dinámico con SOLO las entidades que nos interesan
    let mut builder = DynamicSceneBuilder::from_world(world);
    builder.extract_entities_with_component::<Persist>(); // marker component

    let scene = builder.build();
    let serialized = scene.serialize_ron(&world.resource::<AppTypeRegistry>())?;
    Ok(serialized)
}

fn load_scene(world: &mut World, data: &str) -> Result<(), Box<dyn std::error::Error>> {
    let type_registry = world.resource::<AppTypeRegistry>().clone();
    let mut deserializer = ron::Deserializer::from_str(data)?;
    let scene = DynamicScene::deserialize(&mut deserializer, &type_registry)?;

    // Escribe la scene como un nuevo "template"
    let mut entity_map = bevy::ecs::entity::HashMap::default();
    scene.write_to_world(world, &mut entity_map)?;
    Ok(())
}

Marker components para checkpoints

#[derive(Component)]
struct Persist;       // marca entidades que se guardan

#[derive(Component)]
struct Checkpoint {
    id: CheckpointId,
    triggered: bool,
}

#[derive(Component)]
struct PlayerPersistent {
    hp: i32,
    max_hp: i32,
    position_x: f32,
    position_y: f32,
    inventory: Vec<String>,
}

// Hmm, en realidad solemos separar datos persistibles de datos runtime:
#[derive(Component)]
struct PlayerRuntime {
    // NO se serializa: contiene handles, observers, etc.
    sprite: Handle<Image>,
    velocity: Vec2,
    attack_cooldown: Timer,
}

#[derive(Component)]
struct PlayerPersistentData {
    hp: i32,
    inventory: Vec<String>,
}

Al guardar, solo serializas PlayerPersistentData. Al cargar, creas una nueva entidad con ambos componentes, resolviendo el Handle<Image> desde Assets<Image>.

24.6 Checkpoints: marker + lógica de spawn

Un checkpoint clásico tiene:

  1. Trigger zone (un Collider que detecta al player).
  2. Marker component (#[derive(Component)] struct Checkpoint).
  3. Sistema que escucha el trigger, marca el checkpoint como "activo" y dispara el autosave.
// Bevy NO expone `Commands::run_system(system_fn)` directo. La forma
// idiomática es registrar el sistema una vez y obtener su `SystemId`, y
// después dispararlo con `Commands::run_system(id)` o, si necesitas
// acceso al World completo, `world.run_system_once(id)`.

#[derive(Resource)]
struct SaveSystemId(SystemId);

fn register_save_system(mut commands: Commands, mut world: World) {
    // `register_system` devuelve un `SystemId` que puedes guardar.
    let id = world.register_system(save_game_system);
    commands.insert_resource(SaveSystemId(id));
}

fn autosave_on_checkpoint(
    mut commands: Commands,
    save_id: Res<SaveSystemId>,
    mut events: EventReader<CheckpointReached>,
    checkpoints: Query<&Checkpoint>,
) {
    for ev in events.read() {
        if let Ok(checkpoint) = checkpoints.get(ev.0) {
            info!("Checkpoint {:?} alcanzado.", checkpoint.id);
            // Dispara el sistema registrado por su SystemId.
            commands.run_system(save_id.0);
        }
    }
}
❌ `commands.run_system(save_game_system)` pasando la fn directamente.
No existe esa API. `Commands::run_system` recibe un `SystemId`, que
consigues con `World::register_system(fn)` (o `register_system` en
`App`). Si necesitas ejecutar una fn en medio de un sistema sin
registrarla, usa `world.run_system_once(my_system_fn)` (one-shot).
Checkpoint — marcador de progreso en un juego. Cuando el jugador lo
toca, se guarda la partida para poder continuar desde ahí si muere.

Autosave — guardado automático. En juegos modernos se hace cada X
segundos o tras eventos significativos (checkpoint, boss derrotado,
objeto raro recogido). Obligar al jugador a guardar manualmente es
cosa de los 90.

24.7 Versionado de saves

Esto es importantísimo y casi nadie lo hace bien:

#[derive(Serialize, Deserialize)]
#[serde(tag = "version")]
enum SaveData {
    V1(SaveDataV1),
    V2(SaveDataV2),
}

impl SaveData {
    fn migrate(self) -> SaveDataV2 {
        match self {
            SaveData::V1(v1) => v1.into_v2(),
            SaveData::V2(v2) => v2,
        }
    }
}

O usando serde con un campo de versión embebido (lo que el SettingsPlugin de 0.19 te deja hacer sin magia adicional):

// Patrón manual: el plugin `SettingsPlugin` de 0.19 carga el .ron
// como lo encuentra. Si añades campos nuevos, una `Default` sensata
// suele bastar (serde rellena con default). Para cambios de schema
// incompatibles, mete un campo `version` y migra a mano al cargar.
#[derive(Deserialize)]
struct SaveFileV3 {
    version: u32,
    /* ...campos... */
}

fn migrate_v2_to_v3(raw: serde_json::Value) -> SaveFileV3 {
    let v2: SaveFileV2 = serde_json::from_value(raw).unwrap_or_default();
    v2.into_v3()
}
❌ Cambiar el formato del save sin migrar. Tu juego pasa a 1.1, los
jugadores cargan su save de 1.0, crashea, vuelcan el juego a la basura
y publican una review de 1 estrella en Steam. Esas reviews no se borran.
✅ Empieza con un campo `version: u32` desde el día uno. Te lo imploro.

🎯 24.8 El Patrón del capítulo: "guarda datos, no componentes"

**El componente es para el runtime. El dato es para el disco.

Si tu componente no se puede deserializar limpiamente (Handles,

Observers, timers activos), divídelo en dos: un componente runtime

y un struct de datos persistible. Al guardar, extrae los datos.

Al cargar, reconstruye el componente.**

Las consecuencias prácticas:

  1. is_changed() no detecta cambios internos en sub-campos. Si tienes settings.audio.master = 0.5, hazlo un recurso y compara con PartialEq.
  2. No serialices Timer. Guarda el tiempo restante o el timestamp de fin y reconstruye el Timer al cargar.
  3. No serialices la jerarquía de UI. Salva solo el "menú activo" como un enum y re-spawn el árbol UI en cada cambio.
  4. Versiona desde el día uno. Un campo version: u32 no cuesta nada y te salva la vida en la versión 1.4.

Lo que vimos

En el siguiente

En el capítulo 25 nos metemos en networking ligero: bevy_replicon vs lightyear, autoridad, predicción, y por qué la red miente, tu código lo sabe.