— Filosofía de barra de bar, citada en The Stanley Parable: Ultra Deluxe (Davey Wreden & William Pugh, 2022, Galactic Cafe / Crows Crows Crows)
Vamos a hablar de States. No, no de los estados gramaticales de la lengua española (que esos ya te los explicaba la profe de lengua y no te enterabas). Hablamos de Game States: esas lucecitas de neón que le dicen a tu juego en qué momento de su vida está. ¿Estás en el menú principal? ¿Cargando nivel? ¿Jugando? ¿Pausado? ¿Muerto en combate porque ese slime del tutorial te ha dado tres collejas? Todo eso lo controla un State.
Pilla la analogía y grábatela a fuego:
🟢 El State es el semáforo del juego. Solo se enciende lo que toca.
Un semáforo en rojo significa "no pases". Y cuando el juego está en estado Paused, las systems de movimiento, ataque, IA y demás no se encienden. ¿Por qué? Porque el semáforo está en rojo. Cuando pasa a InGame, se enciende la luz verde y solo entonces esas systems se ejecutan. Si pones todas las systems a la vez sin un semáforo que las filtre, el resultado es un juego que se mueve, dispara, suena música, abre el menú y se pausa a sí mismo al mismo tiempo. Vamos, lo que en la calle se llama un desastre.
En este capítulo vamos a ver:
GameState y su #[derive(States)].App con init_state::<GameState>().OnEnter, OnExit y OnTransition.Exploration dentro de InGame).run_if(in_state(...)).StateTransitionEvent<S>.Y lo más importante: el Patrón del capítulo que te va a salvar la vida (y la cordura).
**State (Estado)**: un valor global del juego que solo puede tomar unos pocos valores discretos (menú, jugando, pausado…). Bevy lo expone como un `Resource` singleton que solo puede mutar a través de transiciones controladas.
**SubState (Sub-estado)**: un State anidado dentro de otro State. Solo existe una variante activa cuando su "padre" está en un estado concreto. Por ejemplo, `Combat` solo es válido cuando el padre es `InGame`.
**ComputedState (Estado Computado)**: un State cuyo valor se deriva automáticamente de otros States o Resources. Tú no lo cambias a mano; Bevy lo recalcula solito cada frame.
**OnEnter(State)**: schedule label que se ejecuta UNA sola vez en el frame en que el State ENTRA en una variante.
**OnExit(State)**: schedule label que se ejecuta UNA sola vez en el frame en el que el State SALE de una variante.
**OnTransition { from, to }**: schedule label que se ejecuta UNA sola vez en una transición concreta de `from` a `to`. Útil para limpiar cosas que ya no sirven al cambiar de estado.
**run_if(in_state(S))**: restricción que se aplica a un sistema (o a un grupo) para que SOLO se ejecute cuando el State `S` está en una variante concreta.
GameState y #[derive(States)]En Bevy, un State es un enum con #[derive(States)]. Cada variante es una "luz" del semáforo. Vamos a definir el nuestro:
use bevy::prelude::*;
#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum GameState {
#[default]
MainMenu,
Loading,
InGame,
Paused,
GameOver,
}
Fíjate en el #[derive(...)]. Es como un conjunto de superpoderes que le damos al enum:
States: el superpoder principal. Le dice a Bevy: "oye, este enum vale para ser un State".Debug: para que puedas hacer println!("{:?}", state).Clone, Copy: porque los enums pequeños se copian, no se clonan. Es más rápido.PartialEq, Eq: para que puedas comparar variantes (if state == GameState::InGame).Hash: porque Bevy usa los States como claves de tablas hash internamente.Default: para que Bevy sepa qué variante usar al arrancar. ¿La primera? No: la que tenga #[default]. Si no pones ninguna, el compilador te odiará.Si olvidas el `#[default]` en una variante, Bevy te soltará un error largo y críptico tipo: *"the trait `Default` is not implemented for `GameState`"*. Y tú ahí, a las tres de la mañana, buscando en Google cosas que no quieres que tu madre lea. Solución: pon `#[default]` en la variante que quieras que sea la inicial. Normalmente `MainMenu` o `Loading`.
Vale, ya tenemos el enum. Pero Bevy no es adivino. Hay que decirle: "eh, quiero un State de tipo GameState". Eso se hace con init_state:
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.init_state::<GameState>()
.run();
}
init_state::<GameState>() mete en la App un Resource especial llamado State<GameState> con el valor inicial (el #[default], o sea MainMenu). A partir de ese momento, cualquier system puede consultarlo con Res<State<GameState>> o mutarlo con NextState<GameState>.
⚠️ Ojo con
add_state::<S>(): en versiones antiguas de Bevy existía este método que registraba el State y añadía los schedulesOnEnter/OnExitde golpe. En Bevy 0.18/0.19 está deprecado o eliminado (consulta el migration guide de tu versión exacta). La API moderna y preferida esinit_state::<S>(), que solo inicializa el resource; los schedules (OnEnter,OnExit,OnTransition) los registras tú explícitamente conadd_systems(...). Esto es más verboso pero mucho más explícito: ves exactamente qué systems están enganchados a cada transición.
fn start_game(
keys: Res<ButtonInput<KeyCode>>,
mut next: ResMut<NextState<GameState>>,
) {
if keys.just_pressed(KeyCode::Enter) {
next.set(GameState::Loading);
}
}
NextState<GameState> es una cola de transiciones. Cuando le haces .set(...) o .reset(), lo que haces es encolar un cambio. Bevy lo aplicará cuando llegue el momento seguro (al final del frame). No puedes cambiar el estado "a lo bruto" desde un system; tienes que pasar por la cola. Esto es para evitar que dos systems a la vez digan "yo quiero que el estado sea este" y acaben peleándose como niños en un parque.
La idea de un "game state machine" global ya estaba en **Quake** (id Software, 1996). El motor guardaba la fase de la partida en un campo `game.state` y tenía un enum de tipo `game_state_t` con valores como `GS_LEVEL`, `GS_INTERMISSION` y `GS_FINALE`. Sí, en C, sin traits, sin derive, sin generics. Los programadores de id Software escribían el `switch` a mano. Y no lloraban. O sí lloraban, pero por otra cosa (los polígonos, que en 1996 eran todos cuadrados).
OnEnter, OnExit, OnTransitionVale, ya tenemos el semáforo. Pero un semáforo solo no hace nada: hace falta lógica que reaccione cuando cambia la luz. Para eso tenemos tres schedule labels mágicos:
OnEnter(GameState::InGame): se ejecuta UNA vez en el frame que el State entra en InGame. Ideal para spawnear el jugador, cargar música, reiniciar el marcador.OnExit(GameState::InGame): se ejecuta UNA vez en el frame que el State sale de InGame. Ideal para limpiar entidades, parar música, guardar partida.OnTransition { from: GameState::Loading, to: GameState::InGame }: se ejecuta UNA vez en una transición concreta. Ideal para cosas intermedias (un fade, una pantalla de "cargando…" que se quita).Ejemplo:
use bevy::prelude::*;
#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum GameState {
#[default]
MainMenu,
Loading,
InGame,
Paused,
GameOver,
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.init_state::<GameState>()
// Al entrar en InGame, spawneamos al jugador
.add_systems(OnEnter(GameState::InGame), spawn_player)
// Al salir de InGame, lo limpiamos todo
.add_systems(OnExit(GameState::InGame), cleanup_world)
// En la transición Loading -> InGame, fade-in
.add_systems(
OnTransition {
from: GameState::Loading,
to: GameState::InGame,
},
fade_in,
)
.run();
}
fn spawn_player(mut commands: Commands) {
commands.spawn((
Name::new("Player"),
Transform::default(),
));
}
fn cleanup_world(mut commands: Commands, query: Query<Entity, With<Name>>) {
for entity in &query {
commands.entity(entity).despawn();
}
}
fn fade_in(mut commands: Commands) {
// Aquí iría un Tween o un componente de fade
println!("¡Fade-in!");
}
💡 Nota importante:
OnEnter,OnExityOnTransitionson schedule labels, no sistemas. Tú les enganchas systems con.add_systems(...)igual que conUpdate. Bevy se encarga de que esos systems corran solo una vez en el momento adecuado.
A veces un solo nivel de "encendido/apagado" no basta. Cuando el juego está en InGame, ¿qué está pasando? ¿El jugador está explorando? ¿Está en combate? ¿Está en un diálogo? ¿Tiene el inventario abierto? ¿Está en una cinemática? Todas son situaciones distintas, con distintas systems activas. Bienvenido a los SubStates.
Un SubState es un enum con #[derive(SubStates)] que tiene un source (el State padre) y un schedule (en qué momento se recalcula). Solo puede estar activo cuando el padre está en una de las variantes que tú definas. Bevy 0.15+ lo trae de fábrica:
use bevy::prelude::*;
#[derive(SubStates, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[source(GameState = GameState::InGame)]
pub enum InGameState {
#[default]
Exploration,
Combat,
Dialogue,
Inventory,
Cutscene,
}
#[source(GameState = GameState::InGame)] significa: "este SubState solo existe cuando GameState está en InGame". Si el padre pasa a Paused, el SubState se queda en Exploration (o lo que sea) pero no se evalúa. Cuando el padre vuelva a InGame, el SubState sigue donde estaba.
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.init_state::<GameState>()
.init_state::<InGameState>() // <- ¡también init!
.add_systems(OnEnter(InGameState::Combat), start_combat_music)
.add_systems(OnExit(InGameState::Combat), stop_combat_music)
.add_systems(
Update,
(
run_ai.run_if(in_state(InGameState::Combat)),
show_dialogue.run_if(in_state(InGameState::Dialogue)),
),
)
.run();
}
fn run_ai(/* ... */) { /* ... */ }
fn show_dialogue(/* ... */) { /* ... */ }
fn start_combat_music(/* ... */) { /* ... */ }
fn stop_combat_music(/* ... */) { /* ... */ }
El concepto de "sub-estados" en máquinas de estado se formalizó en **Mealy** (George H. Mealy, 1955, Bell Labs) y **Moore** (Edward F. Moore, 1956, Bell Labs). Sí, hace 70 años. Los informáticos ya sabían que los estados se anidan. Tu juego indie del 2025 no está inventando nada, solo aplicando lo que un señor de Bell Labs se fumó un martes por la tarde.
Hay veces que un State se puede deducir de otros. Por ejemplo: "el jugador está en combate si y solo si hay un enemigo con HP > 0 a menos de 50 píxeles y la variable in_combat está activada". En lugar de actualizar a mano IsInCombat, puedes usar un ComputedState:
use bevy::prelude::*;
#[derive(ComputedStates, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum CombatReadiness {
#[default]
NotReady,
Ready,
}
impl ComputedStates for CombatReadiness {
type SourceStates = (GameState, InGameState);
fn compute(sources: Self::SourceStates) -> Option<Self> {
match sources {
(GameState::InGame, InGameState::Combat) => Some(Self::Ready),
_ => Some(Self::NotReady),
}
}
}
⚠️ En Bevy 0.18/0.19 el trait
ComputedStatesse puede derivar con macros específicas, pero la forma manual (implementandocompute) sigue siendo la más legible para casos como el de arriba. Si quieres la forma derivada moderna, revisa la API de tu versión exacta. La idea es la misma.
**ComputedState (Estado Computado)**: un State cuyo valor no se cambia a mano sino que se *deriva* automáticamente de uno o varios States fuente. Bevy recalcula su valor en cada cambio de los sources. Tú solo consultas el resultado con `Res<State<CombatReadiness>>`.
Aquí es donde la cosa se pone elegante. Imagina que tienes un Plugin de combate. Solo quieres que sus systems se ejecuten cuando el juego está en InGame::Combat. ¿Cómo?
pub struct CombatPlugin;
impl Plugin for CombatPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
(
enemy_ai,
player_attack,
damage_numbers,
)
.run_if(in_state(GameState::InGame))
.run_if(in_state(InGameState::Combat)),
)
.add_systems(OnEnter(InGameState::Combat), setup_combat)
.add_systems(OnExit(InGameState::Combat), teardown_combat);
}
}
fn enemy_ai(/* ... */) { /* ... */ }
fn player_attack(/* ... */) { /* ... */ }
fn damage_numbers(/* ... */) { /* ... */ }
fn setup_combat(/* ... */) { /* ... */ }
fn teardown_combat(/* ... */) { /* ... */ }
La línea clave es:
.run_if(in_state(GameState::InGame))
.run_if(in_state(InGameState::Combat))
Estos son guards: condiciones que deben cumplirse para que el grupo de systems se ejecute. Si el State no coincide, Bevy se salta esas systems como si no existieran. Cero coste, cero sorpresas.
El concepto de "sistema condicional" ya estaba en **Quake III Arena** (id Software, 1999) bajo la forma de `G_RunFrame` con un switch sobre `level.time` y `game.state`. Los programadores del 99 no tenían `run_if`, pero sí comentarios tipo `// only in combat` por todos lados. La elegancia llegó veinte años después. Bendito Rust.
StateTransitionEventA veces no quieres reaccionar al entrar o al salir de un estado, sino en el momento exacto en que ocurre la transición. Para eso existe el evento StateTransitionEvent<S> (con Event al final; el nombre StateTransition<S> no existe en Bevy 0.18/0.19), que Bevy emite al aplicar el cambio de estado. Sus campos son exited: Option<S> y entered: Option<S>. No hay campo frame; si necesitas el número de frame, léelo de un Resource contador propio o del FrameCount resource de Bevy.
use bevy::prelude::*;
// Firma correcta en Bevy 0.18/0.19: el tipo es "StateTransitionEvent<S>"
// (con "Event" al final), y se lee con EventReader::read().
// Campos disponibles: "exited: Option<S>" y "entered: Option<S>".
// NO existe "ev.frame".
fn log_transitions(
mut events: EventReader<StateTransitionEvent<GameState>>,
) {
for ev in events.read() {
// exited puede ser None si es la primera vez que entra,
// y entered puede ser None si el estado se apaga del todo.
println!(
"Transición: {:?} -> {:?}",
ev.exited, ev.entered,
);
}
}
ev.exited es Some(variante_anterior) (o None si no había estado previo) y ev.entered es Some(variante_nueva) (o None si se ha desactivado). Útil para telemetría, achievements, transiciones animadas o guardar checkpoints en disco. Si necesitas el número de frame, usa Res<FrameCount> y lee su campo.
El error más común (y más caro) al trabajar con States es **olvidar limpiar entidades o desregistrar systems al cambiar de State**. Imagina esto:
- Entras en `InGame` y spawneas 500 enemigos. ✅
- Sales de `InGame` y entras en `MainMenu`. ❌
- Los 500 enemigos siguen vivos. ❌
- Vuelves a `InGame` y spawnas otros 500. ❌
- Ahora tienes 1.000 enemigos. ❌
- Y luego 1.500. Y luego 2.000. ❌
Resultado: tu juego come memoria como si fuera un adolescente en un buffet libre. Esto se llama **leak de escena** y se evita SIEMPRE con `OnExit(State)`. Si algo se spawnea en `OnEnter(InGame)`, ese algo (o su lote completo) tiene que morirse en `OnExit(InGame)`. Es como la ley del paréntesis: lo que abres, lo cierras. Lo que spawneas, lo despawns. No hay término medio.
📐 Patrón del capítulo: un solo State por concern mayor, SubStates para concerns anidados.
Un
GameStatecon cinco variantes (MainMenu, Loading, InGame, Paused, GameOver) es la columna vertebral del juego. Cada concern menor (Combate, Diálogo, Inventario…) vive en un SubState anidado bajoInGame. ComputedStates para derivar cosas que ya se pueden deducir. Plugins con.run_if(in_state(...))para activar/desactivar funcionalidad por concern. Y siempre, SIEMPRE, sistemas deOnExitque limpien lo queOnEnterhaya creado.
Ejemplo de aplicación del patrón:
use bevy::prelude::*;
// ==== STATE PRINCIPAL ====
#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum GameState {
#[default]
MainMenu,
Loading,
InGame,
Paused,
GameOver,
}
// ==== SUB-STATE PARA "qué pasa dentro del juego" ====
#[derive(SubStates, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[source(GameState = GameState::InGame)]
pub enum InGameState {
#[default]
Exploration,
Combat,
Dialogue,
Inventory,
Cutscene,
}
// ==== PLUGIN DE COMBATE: solo vive en InGame::Combat ====
pub struct CombatPlugin;
impl Plugin for CombatPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
OnEnter(InGameState::Combat),
(spawn_enemies, start_combat_music),
)
.add_systems(
OnExit(InGameState::Combat),
(despawn_enemies, stop_combat_music),
)
.add_systems(
Update,
(combat_ai, damage_system)
.run_if(in_state(GameState::InGame))
.run_if(in_state(InGameState::Combat)),
);
}
}
// Stubs
fn spawn_enemies(_: Commands) {}
fn start_combat_music(_: Commands) {}
fn despawn_enemies(_: Commands) {}
fn stop_combat_music(_: Commands) {}
fn combat_ai(_: Query<Entity>) {}
fn damage_system(_: Query<Entity>) {}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.init_state::<GameState>()
.init_state::<InGameState>()
.add_plugins(CombatPlugin)
.run();
}
Y ahora, el sistema de transiciones:
fn press_enter_to_start(
keys: Res<ButtonInput<KeyCode>>,
mut next: ResMut<NextState<GameState>>,
) {
if keys.just_pressed(KeyCode::Enter) {
next.set(GameState::Loading);
}
}
fn loading_done(
mut next: ResMut<NextState<GameState>>,
mut sub: ResMut<NextState<InGameState>>,
) {
next.set(GameState::InGame);
sub.set(InGameState::Exploration);
}
En este capítulo hemos montado el semáforo global del juego:
GameState con #[derive(States)] y sus cinco variantes.init_state::<GameState>() (la API preferida en 0.18/0.19; add_state está deprecado/eliminado).OnEnter, OnExit y OnTransition.InGameState con #[derive(SubStates)] y #[source(...)] para manejar Combat, Dialogue, Inventory, Cutscene y Exploration dentro de InGame. Los SubStates solo existen cuando su State padre está en la variante indicada por source.ComputedStates implementando el método compute(sources), para derivar estado a partir de otros sin mutarlo a mano. Útil cuando un estado es una función pura de otros.CombatPlugin con .run_if(in_state(...)) que solo se activa cuando corresponde.StateTransitionEvent<GameState> (con Event al final) para reaccionar al cambio. Sus campos son exited: Option<S> y entered: Option<S>; no existe ev.frame.OnExit que siempre, SIEMPRE, limpia lo que el OnEnter creó.Regla mnemotécnica para los tres sabores de State de Bevy 0.18/0.19:
States: lo defines tú y lo cambias con NextState<S>. Es la fuente de verdad.SubStates: lo defines tú y lo cambias tú, pero solo cuando su padre está en cierta variante.ComputedStates: lo defines tú pero no lo cambias tú. Bevy lo recalcula a partir de otros States cada vez que estos cambian. Es una función pura.En el 14E vamos a hablar de algo que no es ECS ni organización, pero sin lo cual un juego 2D "se ve plano": shaders WGSL. Vamos a escribir un Material2d custom, a mover vértices con un vertex shader, a teñir un sprite con un fragment shader, y a entender el ciclo CPU↔GPU que tanto intimida cuando lo ves por primera vez. El Material2d es el Sprite con superpoderes: le metes matemáticas y te devuelve pixel art con onda. Spoiler: el primer shader que te salga mal va a ser negro, y está bien. Nos vemos ahí.
Nota de la edición 3.0: en el v2, este párrafo prometía "Schedules y Labels avanzados" para el 14E. Como los schedules ya se cubren en el cap. 7, y como faltaba urgentemente un capítulo de shaders, la edición 3.0 cambia el 14E a "Shaders WGSL" (cap. 14E). La promesa queda más útil, aunque ya no sea la original. Si te interesa la versión antigua de schedules avanzados, mirá el cap. 7, que ya los cubre.