— Anónimo, postmortem de un juego indie que aprendió a la fuerza.
Un juego data-driven es aquel donde la mayor parte del contenido (enemigos, items, NPCs, quests, balance) vive en archivos de datos editables por el usuario, no en código compilado. Los ejemplos canónicos:
.json que se pueden moddear.Para un indie 2D, el data-driven resuelve tres problemas:
Este capítulo enseña las tres técnicas que necesitas:
AssetServer.Tres formatos de datos populares en Rust:
| Formato | Pros | Contras | Cuándo |
|---|---|---|---|
| JSON | Universal, todo el mundo lo conoce. | Verboso, sin comentarios (oficialmente). | APIs, configs simples. |
| TOML | Tiene comentarios, fácil de leer. | Limitado en estructura. | Configs (Cargo.toml style). |
| RON | Como Rust syntax, soporta enums, tuplas. | Menos tooling externo. | Contenido de juego, configs complejas. |
| YAML | Muy legible, comentarios. | Indentación frágil, problemas de seguridad con YAML no confiable. | Raro en Rust. |
Para un juego, RON es el rey. Soporta enums (que son el pan de cada día en Bevy) y la sintaxis se parece a Rust. Ejemplo:
// cap-27B — sección 27B.2: ejemplo RON.
(
name: "Goblin",
hp: 30,
damage: 5.0,
speed: 50.0,
ai: Scout(
flee_threshold: 0.3,
aggro_range: 100.0,
),
)
Para configs simples (configuración del juego, settings), TOML o JSON. Para contenido complejo (NPCs, items, quests), RON.
bevy_settings (Bevy 0.19)Antes de liarte a escribir loaders custom, vale la pena conocer la opción nativa que Bevy 0.19 incorpora en el módulo bevy_settings: el subsistema App Settings. Está pensado exactamente para los settings del usuario (volumen, controles, idioma, dificultad) que deben persistence entre ejecuciones y ser mutables en runtime desde un menú de opciones.
La diferencia conceptual respecto al resto del capítulo: lo que cargamos con bevy_common_assets son datos de contenido (enemigos, items, quests), mientras que bevy_settings gestiona datos de configuración del juego asociados a una identidad de app (un reverse-DNS como com.ejemplo.app). El motor se encarga de localizar el directorio correcto por plataforma (dirs::config_dir() en desktop, sandbox en móvil) y de cargar/guardar los grupos automáticamente.
//! cap-27B — sección 27B.2b: settings persistibles con `bevy_settings` (0.19).
use bevy::prelude::*;
use bevy::settings::{SettingsPlugin, SettingsGroup};
#[derive(SettingsGroup, Resource, Reflect, Default, Clone)]
#[settings_group(file = "audio.ron")]
pub struct AudioSettings {
pub master_volume: f32,
pub sfx_volume: f32,
pub music_volume: f32,
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
// El reverse-DNS determina el directorio de persistencia por plataforma.
.add_plugins(SettingsPlugin::new("com.ejemplo.app"))
// El plugin carga `audio.ron` al arranque y lo inserta como `Resource`.
.register_settings_group::<AudioSettings>()
.run();
}
// Acceso idiomático: como cualquier Resource.
fn apply_volume(settings: Res<AudioSettings>) {
// settings.master_volume, etc.
}
Cuándo usar
bevy_settingsvs.bevy_common_assets. Si el dato describe cómo se comporta el juego y debe sobrevivir entre sesiones (preferencias del usuario), usabevy_settings. Si describe qué contiene el juego (el bestiario, las recetas, los diálogos), usabevy_common_assetscon RON/TOML/JSON y carga desdeassets/. Mezclarlos al revés genera fricción: settings no deberían vivir enassets/(que es read-only en builds empaquetadas) ni el contenido debería serializarse en el directorio de usuario.
Caveat de verificación. La firma exacta de
register_settings_group::<T>(), el método de persistencia a disco y los atributos del derive#[settings_group(file = ...)]están documentados enbevy_settingsdel tagv0.19.0y en la migration guide 0.18→0.19. La estructura general (plugin + grupos derivados + carga al arranque) sí corresponde a la API publicada.
Lo primero: definir el struct que va a matchear el archivo:
//! cap-27B — sección 27B.3: definir el struct.
use serde::{Deserialize, Serialize};
#[derive(Asset, TypePath, Serialize, Deserialize, Debug, Clone)]
pub struct EnemyConfig {
pub name: String,
pub hp: f32,
pub damage: f32,
pub speed: f32,
pub ai: AiConfig,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum AiConfig {
Passive,
Scout {
flee_threshold: f32,
aggro_range: f32,
},
Aggressive {
damage_multiplier: f32,
},
Boss {
phases: u32,
},
}
Y el archivo assets/enemies/goblin.ron:
(
name: "Goblin",
hp: 30.0,
damage: 5.0,
speed: 50.0,
ai: Scout(
flee_threshold: 0.3,
aggro_range: 100.0,
),
)
Caveat importante: para que Bevy pueda cargar este asset, necesita un AssetLoader. Para RON, podés usar el crate bevy_common_assets o escribir el tuyo (no muy difícil). Para JSON y TOML, hay loaders oficiales en bevy_common_assets.
bevy_common_assets# cap-27B — sección 27B.4: Cargo.toml.
[dependencies]
bevy = { version = "0.19", features = ["2d"] }
bevy_common_assets = { version = "0.17", features = ["ron", "toml", "json"] }
serde = { version = "1", features = ["derive"] }
//! cap-27B — sección 27B.4: registrar loaders.
use bevy::prelude::*;
use bevy_common_assets::ron::RonAssetPlugin;
use bevy_common_assets::toml::TomlAssetPlugin;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(RonAssetPlugin::<EnemyConfig>::new(&["ron"]))
.add_plugins(TomlAssetPlugin::<GameSettings>::new(&["toml"]))
.run();
}
Y para usar:
//! cap-27B — sección 27B.4: cargar y usar.
use bevy::prelude::*;
fn spawn_goblin(
mut commands: Commands,
asset_server: Res<AssetServer>,
) {
let config: Handle<EnemyConfig> = asset_server.load("enemies/goblin.ron");
// El handle se puede guardar y usar más tarde cuando el asset esté cargado.
commands.spawn(EnemySpawner { config });
}
#[derive(Component)]
struct EnemySpawner {
config: Handle<EnemyConfig>,
}
fn spawn_loaded_enemies(
mut commands: Commands,
configs: Res<Assets<EnemyConfig>>,
spawners: Query<(Entity, &EnemySpawner)>,
) {
for (entity, spawner) in spawners.iter() {
if let Some(config) = configs.get(&spawner.config) {
commands.entity(entity).despawn();
commands.spawn((
Name::new(config.name.clone()),
Health { current: config.hp, max: config.hp },
CombatStats { damage: config.damage, range: 30.0 },
Transform::from_xyz(0.0, 0.0, 0.0),
// Convertir el AI config en componentes.
AiBehavior::from_config(&config.ai),
));
}
}
}
Truco: el Handle<EnemyConfig> es async (se carga en background). El Assets<EnemyConfig>::get devuelve None mientras se está cargando, Some(config) cuando está listo. Tu sistema debe tolerar el None y no asumas que el asset está listo al primer frame.
Para que un archivo .ron defina una entidad entera, lo más limpio es un patrón factory: una función que toma el dato y devuelve un Bundle.
//! cap-27B — sección 27B.5: factory.
use bevy::prelude::*;
pub fn enemy_from_config(config: &EnemyConfig) -> impl Bundle {
(
Name::new(config.name.clone()),
Health { current: config.hp, max: config.hp },
CombatStats { damage: config.damage, range: 30.0 },
Velocity::default(),
Enemy,
Goblin, // componente marcador.
AiBehavior::from_config(&config.ai),
)
}
Y la función from_config del AiBehavior decide qué componentes agregar:
//! cap-27B — sección 27B.5: from_config.
impl AiBehavior {
pub fn from_config(ai: &AiConfig) -> impl Bundle {
match ai {
AiConfig::Passive => (AiBehavior::Passive,),
AiConfig::Scout { flee_threshold, aggro_range } => (
AiBehavior::Scout,
FleeWhenLow { threshold: *flee_threshold },
AggroRange { value: *aggro_range },
),
AiConfig::Aggressive { damage_multiplier } => (
AiBehavior::Aggressive,
DamageMultiplier { value: *damage_multiplier },
),
AiConfig::Boss { phases } => (
AiBehavior::Boss,
BossPhases { remaining: *phases },
),
}
}
}
Esto resuelve el problema de "cómo cargo un enemigo con un Scout AI que tiene flee_threshold y aggro_range": cada variante del enum tiene sus propios componentes asociados. El factory los agrega en un solo Bundle.
Con AssetPlugin { watch_for_changes: true, ..default() } y la feature filesystem_watcher, Bevy recarga los assets cuando los archivos cambian en disco. Para datos de juego (enemigos, items), esto es un cambio de vida para los game designers:
//! cap-27B — sección 27B.6: configurar hot-reload.
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(AssetPlugin {
watch_for_changes: true,
..default()
}))
.add_plugins(RonAssetPlugin::<EnemyConfig>::new(&["ron"]))
.run();
}
Workflow:
assets/enemies/goblin.ron.hp: 30 a hp: 50.EnemySpawner correspondiente está vivo, se "re-spawnea" con la nueva config. O tu sistema detecta el cambio del Handle y actualiza la entidad.Caveat: si cambias la estructura del struct (agregás un campo nuevo), Bevy no puede recargar el asset (asume que la versión compilada es la única). Solución: reinicia el juego. Para hot-reload de struct shape, necesitás un sistema de migración más complejo (no vale la pena para juegos indie).
mods/El modding clásico de los juegos indie: el usuario descarga un .zip con archivos, lo descomprime en una carpeta mods/ al lado del binario, y el juego lo carga. Implementación:
//! cap-27B — sección 27B.7: cargar mods.
use bevy::prelude::*;
use std::path::Path;
fn load_mods(mut commands: Commands, asset_server: Res<AssetServer>) {
let mods_dir = if cfg!(target_family = "wasm") {
// En web, no hay filesystem directo. Los mods se cargan por URL.
Path::new("mods/")
} else {
// En nativo, un path relativo al working directory.
Path::new("mods/")
};
// Listar los archivos .ron en mods/.
if let Ok(entries) = std::fs::read_dir(mods_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("ron") {
let full_path = path.to_str().unwrap();
println!("Cargando mod: {}", full_path);
// Cargar como asset.
let handle: Handle<EnemyConfig> = asset_server.load(full_path);
commands.spawn(EnemySpawner { config: handle });
}
}
}
}
Para los mods más complejos, usá una convención: cada mod es un subdirectorio con un mod.ron (manifest) que describe qué archivos incluye:
// mods/mi-mod/mod.ron
(
name: "Mi Mod",
version: "1.0",
author: "yo",
enemies: ["enemies/custom_goblin.ron", "enemies/orc_warrior.ron"],
items: ["items/custom_sword.ron"],
)
Y tu juego lee el manifest, sabe qué cargar, y evita duplicar archivos que ya estén en el juego base.
Si dos mods modifican al mismo enemigo (enemies/goblin.ron), ¿cuál gana? Tres estrategias:
Para un indie, orden de carga (alfabético) es suficiente. Documentá en el README: "los mods se cargan en orden alfabético, el último sobrescribe". Si querés más, mirá cómo lo hacen Stardew Valley (un sistema de conflictos manual) o RimWorld (automático pero complejo).
Data-driven: propiedad de un juego donde el contenido vive en archivos de datos, no en código.
Mod / modding: modificación hecha por el usuario, no por el desarrollador. En juegos indie, suele ser soportada oficialmente.
RON: formato de datos de Rust, similar a Rust syntax. Soporta enums, tuplas, comentarios.
TOML: formato de config, popular en Rust (Cargo.toml). Soporta comentarios, estructura clara.
JSON: formato universal. Sin comentarios oficialmente (aunque el standard ECMA-2020 los permite).
YAML: formato legible, popular en DevOps. Problemas de seguridad con datos no confiables.
bevy_settings (módulo/crate 0.19): subsistema nativo para settings persistibles del usuario (audio, controles, idioma). NO para contenido del juego.
SettingsPlugin (plugin 0.19): activa el subsistema; toma un reverse-DNS name y carga/guarda grupos al arranque.
SettingsGroup (derive/trait 0.19): struct del usuario asociado a un archivo de persistencia (RON/JSON).
bevy_common_assets: crate de la comunidad (v0.17.0, jun 2026) con loaders para JSON, TOML, RON, msgpack, postcard, yaml, csv, cbor, xml.
AssetLoader: trait de Bevy para definir cómo se carga un tipo de asset.
Serde: crate de Rust para serializar/deserializar structs a/desde formatos. Foundation de todo el data-driven.
Bundle: struct que contiene varios componentes. Spawnable en un solo commands.spawn().
Factory (patrón): función que crea una entidad a partir de datos.
Handle: referencia a un asset en Bevy. Es async: el asset se carga en background.
Mod conflict: cuando dos mods modifican el mismo archivo. Necesita una política de resolución.
Mod priority: número que indica qué mod gana en caso de conflicto. Mayor = más prioridad.
Hot-reload: recargar un asset sin reiniciar el juego.
Manifest: archivo de descripción de un mod. Dice qué archivos incluye, quién lo hizo, qué versión.
Versionado: sistema para manejar cambios incompatibles. Un cambio de major version requiere migración.
Migration: código que convierte un asset de una versión antigua a la nueva. Mismo concepto que save migrations.
Workshop de Steam: plataforma de mods de Steam. Si publicás en Steam, tus mods pueden aparecer ahí.
Nexus Mods: plataforma externa de mods, popular para juegos Bethesda, Stardew, etc.
Doom (id Software, 1993) — uno de los primeros juegos mainstream con modding oficial. La comunidad creó WADs (Where Is Anything Dropped) con niveles y assets nuevos. La cultura del modding arranca acá.
Quake (id Software, 1996) —继承了 Doom y agregó modding más profundo. La comunidad hizo "total conversions" (juegos enteros sobre el motor de Quake: *The Tenebrae*, *Action Quake 2*).
Rogue (1985) — un juego de dungeon procedural, no de modding. Pero la idea de "el mundo se genera" inspiró una cultura de juegos donde el contenido es dinámico, no fijo.
Stardew Valley (ConcernedApe, 2016) — el caso de modding indie más exitoso. SMAPI (la API de mods) es un proyecto de la comunidad, no del desarrollador. ConcernedApe lo aceptó oficialmente.
Binding of Isaac (Edmund McMillen, 2011) — diseñado desde el día uno con modding en mente. La estructura XML/XML2 del juego es un ejemplo de data-driven.
Minecraft (Mojang, 2011) — el modding es un pilar. Forge, Fabric, las dos plataformas de mods. La comunidad de mods de Minecraft es enorme.
Larian Studios / Baldur's Gate 3 (2023) — el modding oficial llegó en 2024, con el "Mod Manager" oficial. Un AAA aprende del modding indie.
Valve / Steam Workshop (2011) — la primera plataforma oficial de mods. Cualquier juego con Workshop habilitado tiene mods descargables desde Steam.
Bethesda / Creation Club (2015) — la plataforma de mods de pago de Bethesda. Controversia: ¿los mods deberían ser gratis o de pago? Depende de la comunidad.
bevy_common_assets (2024) — el crate que estandariza la carga de JSON, TOML, RON, msgpack y otros en Bevy. Antes, cada juego escribía su propio loader.
RON (Porter-libs, 2019) — el formato de datos que los Bevy-devs adoptaron. Diseñado para ser legible y compatible con la sintaxis de Rust.
❌ Hardcodear enemigos en el código: `commands.spawn((Enemy, Goblin, Health { 30.0 }))`.
✅ Cargar desde archivo: `assets/enemies/goblin.ron` con `EnemyConfig`.
💡 Por qué: hardcodear te obliga a recompilar para cada cambio. Los game designers no pueden tocar el balance sin pedirte al programador. Multiplica el tiempo de iteración por 10.
❌ Cargar mods sin verificar que no rompen el juego (mod malformado = panic).
✅ Validar el mod al cargar. Si es inválido, mostrar un error y seguir con el juego base.
💡 Por qué: los mods los hace la comunidad. Un mod malformado no debería crashear tu juego. Validá y reportá.
❌ Asumir que el asset está listo al primer frame (`configs.get(handle).unwrap()`).
✅ Usar el `Handle<T>` como async: `if let Some(config) = configs.get(handle)`.
💡 Por qué: el `AssetServer` carga en background. Si pedís el asset antes de que esté listo, `get` devuelve `None`. Asumir que está listo = panic en producción.
❌ Cargar TODOS los archivos de una carpeta de mods sin filtrar.
✅ Filtrar por extensión (.ron, .toml, .json) y por manifest.
💡 Por qué: si el usuario mete un `.exe` o un `.dll` en la carpeta de mods, tu loader lo intenta parsear y falla. Filtrá.
Problema: tu juego tiene 50 enemigos, 100 items, 30 NPCs. El balance es un caos. Cada cambio requiere una nueva build. Los game designers dependen del programador.
Solución: mové todo el balance a archivos de datos. El código queda como "el motor", los datos como "el contenido". La regla:
[Código] = cómo funciona (sistemas, componentes, factories).
[Datos] = qué existe (enemigos, items, NPCs, dungeons).
[Game designer] toca solo los datos.
[Programador] toca solo el código.
Cuándo sí:
Cuándo no:
.ron.bevy_common_assets y Handle<EnemyConfig>.watch_for_changes.Capítulo 28: el cap 28 original construyó un Mini Metroidvania en 8 milestones. Ahora vamos a ver dos cosas que el cap 28 no atacó: (1) cómo migrar ese Mini Metroidvania a una versión "publicable" (cambios de código para producción), y (2) cómo migrar el juego a una nueva versión de Bevy cuando salga 0.20. Spoiler: el 90% del trabajo está en actualizar crates terceros, no el código de Bevy.