Hasta aquí todos tus systems vivían en una sola cajón llamado Update. Bevy los ejecutaba todos en cada frame, en "el orden que considere", y como casi no había dependencias entre ellos, parecía que todo funcionaba por arte de magia. Pero cuando un juego crece, las dependencias brotan: este system debe ir antes que este, este otro depende de que tal evento se haya procesado, aquel necesita física ya resuelta. Ahí es donde los schedules y los system sets entran a salvarte la vida (y la cordura).
Un schedule es, en pocas palabras, una colección de systems que se ejecutan juntos siguiendo un grafo de dependencias. Bevy no se limita a correrlos en el orden en que los registraste: analiza qué componentes/resources pide cada system y construye un grafo en el que:
ResMut<Config> se marca como conflictivo con cualquier otro que pida ResMut<Config>. O se ordenan explícitamente, o corren en serie uno detrás del otro.Esa tercera propiedad es la que causa el 90 % de los bugs "misteriosos" en juegos Bevy medianos. Y es justo lo que el schedule te ayuda a domar.
- **Schedule**: colección de systems con un grafo de dependencias. Cada uno de los "labels" que ves al registrar (Update, Startup, PreUpdate, ...) es un schedule distinto con su propio grafo.
- **System set**: grupo lógico de systems dentro de un schedule. Te permite decir "todas las físicas van antes que todo el render" sin tener que ordenar system por system.
- **Ordering constraint**: una arista del grafo que dice "A debe ir antes que B". Se expresa con .before(...) y .after(...).
- **Ambigüedad**: cuando dos systems sin dependencia explicita podrían pisarse y Bevy no puede demostrar que es seguro. Por defecto es un warning, no un panic.
Startup, Update y familiaBevy ya trae un conjunto de schedules etiquetados que cubren el 99 % de las necesidades. Cada uno corre en un punto fijo del loop del frame:
First — lo primero que pasa en el frame. Suele usarse para preparar caches, recibir input, etc.PreUpdate — antes del update principal. Aquí va lógica que otros systems del Update van a necesitar ya resuelta: parsing de input en structs, cálculo de eventos pendientes, etc.StateTransition — punto en el que Bevy aplica los cambios de State (lo tratas en el Cap. 14D).RunFixedMainLoop — decide cuántas veces hay que correr el FixedMain este frame según el acumulador de tiempo.FixedMain → FixedUpdate — sistemas de paso fijo (física, por ejemplo). Corre un número variable de veces por frame para mantener el timestep constante.Update — el schedule por excelencia, donde va el grueso de tu gameplay. Es el que llenas con .add_systems(Update, mi_sistema).PostUpdate — tras el Update. Útil para limpieza, sync de cachés, propagación de cambios a render.Last — cierre del frame. Aquí se rotan los buffers de eventos, se actualizan recursos globales, etc.¿Cuándo se registran? En el buildeo de la App:
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup) // 1 vez al arrancar
.add_systems(PreUpdate, parsear_input) // cada frame, antes que Update
.add_systems(Update, (mover, animar)) // cada frame, bucle principal
.add_systems(PostUpdate, sincronizar_render)
.add_systems(FixedUpdate, paso_fisico) // timestep fijo
.run();
}
No tienes que usarlos todos. La mayoría de juegos solo tocan Startup, Update y FixedUpdate. El resto existe para que tengas "puntos de enganche" canónicos cuando crezcas.
FixedUpdate: el secreto de la física estableSi metes tu simulación de física en Update, sufrirás el clásico problema del "a 144 fps mi personaje salta más alto": el timestep varía frame a frame y las integraciones numéricas se vuelven inestables. FixedUpdate resuelve eso corriendo con un delta fijo (por defecto 60 Hz) y ejecutándose un número variable de veces por frame para mantener el acumulador a cero.
// Configurar timestep fijo a 90 Hz:
App::new()
.insert_resource(Time::<Fixed>::from_hz(90.0))
.add_systems(FixedUpdate, integrar_fisica)
.run();
fn integrar_fisica(time: Res<Time>, mut q: Query<&mut Velocity>) {
// Aquí `time.delta_secs()` SIEMPRE es 1/90 s.
let dt = time.delta_secs();
for mut v in &mut q { v.0 *= 0.98; }
}
Truco que ahorra disgustos: dentro de FixedUpdate, time.delta_secs() es siempre el timestep fijo. Fuera, en Update, es el delta real del frame. No los mezcles. Si necesitas el delta real desde un system fijo (raro pero a veces útil), pide Res<Time<Real>>.
.before() y .after()Cuando sí necesitas un orden concreto entre systems, usas los métodos .before(...) y .after(...) en la firma de .add_systems. Ambos reciben o el nombre de una función, o un SystemSet, o un SystemId:
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Update, (
mover_player,
camara_sigue_player.after(mover_player),
actualizar_hud.after(camara_sigue_player),
))
.run();
}
Es equivalente usar camara_sigue_player.after(mover_player) o mover_player.before(camara_sigue_player): eliges el que lea más natural. Y puedes encadenar: el ejemplo de arriba crea una cadena mover → cámara → HUD que se respeta estrictamente cada frame.
Cuando tienes 30 systems, estar poniendo .before(...) y .after(...) a cada uno es insostenible. Ahí entran los system sets: etiquetas lógicas que agrupan systems y te permiten ordenar el grupo de golpe.
use bevy::prelude::*;
// 1. Define tus sets como enums que implementen SystemSet.
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
enum GameplaySet {
Input,
Movimiento,
Fisica,
Combate,
Render,
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
// 2. Configura el orden de los sets UNA vez.
.configure_sets(
Update,
(
GameplaySet::Input,
GameplaySet::Movimiento,
GameplaySet::Fisica,
GameplaySet::Combate,
GameplaySet::Render,
).chain(),
)
// 3. Asigna cada system a su set.
.add_systems(Update, (
parsear_input.in_set(GameplaySet::Input),
mover_player.in_set(GameplaySet::Movimiento),
integrar_avian.in_set(GameplaySet::Fisica),
aplicar_dano.in_set(GameplaySet::Combate),
sincronizar_sprites.in_set(GameplaySet::Render),
))
.run();
}
La magia de .chain(): convierte la tupla en una cadena ordenada estricta. Si en el futuro añades cinematica a GameplaySet::Render, automáticamente corre tras Combate, sin tocar el resto. Es la forma escalable de organizar un proyecto real.
**Metedura de pata**: olvidarte de los derives al definir un SystemSet. El enum TIENE que llevar `#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]` (los cinco). Si falta `Hash`, no compila. Si falta `SystemSet`, `.in_set(...)` no lo acepta. Copia-pegalo siempre; no te pongas creativo.
ambiguous_with, ambiguous_all y el modo assertLlega el momento clave del capítulo, y probablemente el peor explicado en la documentación oficial. Vamos con calma.
Cuando Bevy construye el grafo del schedule, busca conflictos de acceso entre cada par de systems. Si dos systems piden ResMut<LoQueSea> del mismo tipo, o Query<&mut Transform> sin filtros disjuntos, Bevy no puede saber en qué orden tocarlos es seguro, así que los serializa (los corre uno detrás del otro, sin paralelismo entre ellos). Hasta aquí, todo correcto.
Pero hay un segundo caso más sutil: dos systems que no tienen conflicto de acceso (no se pisan) pero tampoco tienen una dependencia explicita. Por ejemplo, audio_beep y mover_player: ninguno toca lo del otro. En ese caso, Bevy no sabe si el orden entre ellos importa. Eso es una ambigüedad.
Y aquí corregimos un mito popular del que se ha escrito mucho por internet: "Bevy 0.17/0.18 te obliga a declarar el orden entre cada par de systems o te da panic". Es falso. El comportamiento por defecto es:
"system X has ambiguities with Y" la primera vez que lo detecta. Tu juego corre perfectamente. El warning es una pista, no un muro.bevy_ecs/debug-plugin y la flag assert_schedule, o llamando tú mismo a Schedule::graph().assert_no_ambiguities() en un test.En otras palabras: en desarrollo normal, una ambigüedad es ruido en logs, no un bloqueo. Que no te asuste el aviso. Que tampoco lo ignores: si el orden realmente no te importa, decláralo así, y de paso quitas el warning y ayudas a Bevy a paralelizar mejor.
Aquí entran las dos herramientas que el informe pedía cubrir:
// 1. `ambiguous_with` marca DOS systems concretos como "no importa el orden".
.add_systems(Update, (
audio_beep,
mover_player,
).ambiguous_with_each_other())
// 2. Equivalente uno a uno:
.add_systems(Update, audio_beep.ambiguous_with(mover_player))
// 3. `ambiguous_all` marca un system como "no importa el orden con nadie".
// Útil para systems puramente observadores (estadísticas, telemetría, logs).
.add_systems(Update, metricas_fps.ambiguous_all())
Cuándo usar cada uno:
ambiguous_with: cuando sabes que dos systems específicos son independientes y no necesitas forzar orden. Es el caso más común y el más seguro: estás diciendo "he pensado en este par concreto y no hay problema".ambiguous_all: para sistemas "spies" que solo leen y no afectan a nadie: métricas, logging, depuración. Cuidado: si lo usas en un system que SÍ escribe algo, estás mintiendo al scheduler y puede generar bugs sutiles.assert_no_ambiguitiesPara juegos serios conviene cazar ambigüedades (y conflictos) en integración continua antes de que lleguen a producción. La API canónica es Schedule::assert_no_ambiguities, que paniquea si encuentra algún par de systems sin ordenar que el scheduler no pueda justificar. El patrón es un test que cargue tu App, extraiga el schedule de Update (o el que sea) y lo valide:
// En tests/integration/schedule.rs
use bevy::prelude::*;
#[test]
fn no_hay_ambiguedades_inesperadas() {
let mut app = App::new();
mi_app::construir(&mut app); // registra plugins, systems, etc.
// Sacamos el schedule `Update` del resource `Schedules` y validamos.
let mut schedules = app.world_mut().resource_mut::<Schedules>();
let schedule = schedules.get_mut(Update).unwrap();
schedule.assert_no_ambiguities(); // panickea si hay ambiguos sin declarar.
}
También puedes activar el detector de ambigüedades en tiempo de ejecución (no solo en test) llamando a Schedule::set_build_settings con BuildSettings::ambiguity_detection = true. Eso imprime warnings adicionales mientras juegas, lo que es útil para cazar pares conflictivos que solo aparecen en ciertos estados del juego.
La regla de oro: en dev, warnings activos (para pillar el problema). En CI, assert_no_ambiguities activo (para que no entre nada nuevo sin explicitar). En release, da igual, porque el schedule ya está validado y el coste del check se evita.
A veces no quieres ordenar un system, sino simplemente decidir si corre o no. Bevy tiene para eso las run conditions: funciones que devuelven bool y que se asocian a un system (o a un set) con .run_if(...). Si la condición devuelve false, el system ni siquiera se ejecuta, y su slot de schedule queda libre.
// System que solo corre cuando estamos en estado `GameState::Playing`.
.add_systems(Update, (
mover_player.run_if(in_state(GameState::Playing)),
actualizar_hud.run_if(in_state(GameState::Playing)),
))
// Condition custom: solo corre si hay un player vivo.
fn hay_player(query: Query<&Player>) -> bool {
!query.is_empty()
}
.add_systems(Update, game_over_logic.run_if(hay_player))
Bonito detalle: las condiciones se evalúan antes de pedir los datos del system. Si la condición devuelve false, los accesos del system no se tienen en cuenta para el grafo, lo que a veces elimina ambigüedades "fantasma". Útil para: sistemas de pausa, de depuración, de menús, de cinemáticas, decheats, etc.
par_iterAhora vamos al Santo Grial de ECS para juegos grandes. Bevy ya paraleliza automáticamente systems que no se pisan. Pero dentro de UN system, el bucle for item in &query es secuencial: un solo hilo, una sola entity a la vez. Para miles de entities eso ya es rápido, pero para cientos de miles (bullet hell, RTS con 10k unidades, simulaciones de partículas) te quedas corto en CPU.
Ahí entra Query::par_iter (y su primo mutante par_iter_mut), que reparte el trabajo entre los cores del procesador usando rayon bajo el capó:
fn mover_miles_de_balas(
time: Res<Time>,
mut balas: Query<&mut Transform, With<Bala>>,
) {
let dt = time.delta_secs();
balas.par_iter_mut().for_each(|mut transform| {
transform.translation.y += 800.0 * dt;
});
}
Eso corre en paralelo entre todos tus cores. La sintaxis es la de rayon: for_each, map, filter, fold, etc. Y como cada entity se procesa de forma aislada (Bevy garantiza que cada &mut Transform es distinto), no hay data races.
par_iterCommands dentro del closure (no es seguro). Para insertar/despawnear, acumula resultados en una colección local y ejecuta los commands fuera.Query::get de la MISMA query desde dentro (aliasing).for secuencial suele ser más rápido que par_iter. La regla práctica: empezar con for secuencial, perfilar, y cambiar a par_iter solo cuando el profiler diga que ahí está el cuello.par_iter es más lento que el bucle plano. No saques conclusiones del profile en debug.★ **Touhou Project (ZUN, 1995–)** — la serie que definió el "bullet hell". Los juegos de ZUN tienen pantallas con 500+ balas simultáneas y mantienen 60 fps estables en hardware modesto. Su truco no era paralelismo (corría en un solo hilo), sino estructura de datos en arrays planos: un `Transform[500]` secuencial es caro de iterar para la CPU, pero baratísimo para la memoria caché L1. Cache locality beats parallelism, casi siempre. par_iter te da el bonus cuando ya no te queda más cache locality que exprimir.
SystemParam custom: envolviendo lógica repetidaEl último truco avanzado del capítulo. Llega un punto en todo proyecto Bevy en que varios systems comparten los mismos parámetros: "dame el player, la cámara, el input y el resource de configuración de gameplay". Repetir esa firma en 20 sistemas es feo y frágil. La solución es definir tu propio SystemParam:
use bevy::prelude::*;
use bevy::ecs::system::SystemParam;
// Un "paquete" de parámetros que agrupa el contexto del jugador.
#[derive(SystemParam)]
struct ContextoJugador<'a> {
// Nota: SystemParam structs llevan lifetime explícito.
player: Query<&'a Transform, With<Player>>,
input: Res<'a, ButtonInput<KeyCode>>,
config: Res<'a, GameplayConfig>,
}
impl ContextoJugador<'_> {
// Métodos helper que encapsulan lógica común.
fn player_pos(&self) -> Option<Vec3> {
self.player.single().ok().map(|t| t.translation)
}
fn quiere_saltar(&self) -> bool {
self.input.just_pressed(self.config.tecla_salto.clone())
}
}
// Ahora cada system que necesite este contexto lo pide como un parámetro.
fn sistema_de_salto(ctx: ContextoJugador, mut commands: Commands) {
if ctx.quiere_saltar() {
if let Some(pos) = ctx.player_pos() {
commands.trigger_event(SaltoEjecutado { desde: pos });
}
}
}
Detalles finos:
SystemParam requieren lifetime explícito. Es un poco molesto pero es el precio del entry.ReadOnlySystemParam, que permite al scheduler ejecutar tu system en paralelo con otros de solo-lectura. Si necesitas ResMut o Query<&mut T>, sigue siendo SystemParam a secas.SystemParam dentro de otros. Bevy lo resuelve recursivamente.**Metedura de pata**: meter Commands o World dentro de un SystemParam y liarla. Commands SÍ es SystemParam y se puede incluir, pero combinado con Query<&mut T> a veces genera ambigüedades de schedule imposibles de resolver. Si tu param crece hasta 7+ campos, probablemente estés agrupando cosas que deberían ser systems distintos. La regla: SystemParam para reducir boilerplate de lectura, no para construir un megasistema.
Para proyectos muy grandes (motor propio encima de Bevy, herramientas de editor, simulaciones científicas) a veces necesitas un schedule propio con su propio grafo. El patrón canónico en 0.19 es: deriva un ScheduleLabel, registra el schedule en la App, añádele systems como a cualquier otro, y ejecútalo cuando quieras (manualmente o hooked a un punto del loop):
use bevy::prelude::*;
// 1. Define una etiqueta para tu schedule.
#[derive(ScheduleLabel, Debug, Hash, PartialEq, Eq, Clone)]
struct SimulacionIa;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
// 2. Inicializa el schedule (crea el Schedule vacío en Schedules).
.init_schedule(SimulacionIa)
// 3. Añade systems con tu label, igual que con Update.
.add_systems(SimulacionIa, (
percibir_enemies,
decidir_accion,
ejecutar_accion,
).chain())
.run();
}
// 4. Lo ejecutas desde otro system cuando toque.
fn tick_ia(world: &mut World) {
world.try_run_schedule(SimulacionIa).ok();
}
Dos detalles finos: try_run_schedule(label) busca el schedule en el resource Schedules y lo ejecuta sobre el World que le pases. Si no existe el schedule (lo olvidaste registrar), devuelve Err y por eso el .ok(). El .chain() en el add_systems fuerza el orden estricto percibir → decidir → ejecutar; sin él, Bevy decidiría el orden por los accesos y podría paralelizar lo que no se pise.
Esto es avanzado y raro. Si estás leyendo esto por primera vez, probablemente no lo necesitas. Pero está ahí para cuando tu motor crezca.
Patrón del capítulo: "Si el orden importa, dilo; si no, déjalo fluir"
>
Regla: nunca dejes el orden entre dos systems al "como quede". Si hay una dependencia funcional (A lee lo que B escribe, o A necesita que B haya corrido antes), exprésala con
.before()/.after()o con unSystemSet. Si no hay dependencia y el orden es indiferente, exprésalo también con.ambiguous_witho.ambiguous_all. Lo único que no debes hacer es callar: el silencio es lo que genera warnings y bugs "misteriosos".
>
Por qué funciona: cuando declaras todas tus dependencias, el grafo de Bevy pasa de "intento adivinar" a "sé exactamente qué hacer". Tu schedule se paraleliza mejor, los warnings desaparecen, y cualquier system nuevo que añadas queda encajado por sus conflictos de acceso sin que tengas que pensar dónde meterlo.
>
Antipatrón: ordenar TODO con
.chain(). Una cadena única de 50 systems corre estrictamente en serie y desperdicia todos tus cores salvo uno.chaines para lo que realmente necesita orden; deja que Bevy paralelice el resto.
>
Antipatrón 2: usar
ambiguous_allpara acallar warnings sin pensar. Es el equivalente a poner#[allow(unused)]en cada file de Rust: funciona, pero estás ocultando información que en el futuro te habría avisado de un bug.
Tienes estos systems:
parsear_input,mover_player,integrar_fisica_avian,comprobar_colisiones,actualizar_hud,reproducir_sonidos,loggear_fps. ¿Cómo los organizarías en system sets? ¿Qué pares marcarías comoambiguous_with? ¿Dónde pondrías un.run_if(in_state(...))? Piensa en el grafo que dibujaría Bevy, y en cuántos hilos se podría repartir.
Pista: cinco o seis sets en .chain() (Input → Movimiento → Física → Colisiones → HUD), más dos systems "observadores" (reproducir_sonidos que solo lee eventos, loggear_fps que solo lee recursos) marcados con ambiguous_all. Tu schedule debería quedar casi sin warnings y paralelizar sano entre cores.
★ **id Tech 4 / Doom 3 (id Software, 2004)** — el motor introdujo un sistema de "system stages" que agrupaba rendering, physics, AI y gameplay en fases estrictas. Era básicamente un SystemSet con orden topológico. John Carmack lo llamó "deterministic ordering" y lo consideró crítico para la reproducibilidad de las partidas grabadas (demo recording).
★ **Unity DOTS (Unity Technologies, 2018–)** — la reescritura ECS de Unity introdujo SystemGroup, que es exactamente la misma idea que el SystemSet de Bevy. La coincidencia no es casual: ambos motores adoptaron la misma solución (fases + dependencias explícitas) al mismo problema (ordenar miles de systems sin volverte loco).
★ **Bevy 0.6 (2022)** — introdujo la separación entre SystemSet y SystemLabel, que antes eran la misma cosa. La limpieza permitió cosas como .chain() y .ambiguous_with, que se han vuelto canónicas.
First, PreUpdate, Update, PostUpdate, Last, FixedUpdate) dan puntos de enganche canónicos sin que tengas que inventarlos..before() / .after(); los grupos con SystemSet + .chain()..ambiguous_with y .ambiguous_all; en CI se elevan a panic con assert_no_ambiguities..run_if(...)) deciden si un system corre o no, sin tocar el cuerpo del system.Query::par_iter / par_iter_mut, con cuidado del overhead y las restricciones de acceso.#[derive(SystemParam)] y ReadOnlySystemParam, encapsulando parámetros comunes en un único tipo.En el cap 8 vamos con los required components: cómo decirle a Bevy que un componente "se trae amigos", de forma que spawnea Player y se instancian automáticamente Transform, Visibility, Vida y lo que haga falta. Es la pieza que faltaba para que ECS compita con la "herencia" de los motores orientados a objetos, sin perder ni un ápice de composicionalidad. Spoiler: cambia tu forma de diseñar prefabs para siempre.