Capítulo 14C. Catálogo de arquitecturas ECS por género 2D

CAP 14C · Bevy 0.18/0.19
"Un género no es un set de reglas, es una manera de aburrirse."

— Anónimo, posiblemente con un roguelike en la otra pantalla (Hades, 2020, Supergiant Games).

Bienvenido al catálogo más largo del libro. Aquí vamos a abrir diez puertas distintas y mirar dentro: cada género 2D tiene su propio "mood", su propio tipo de entidad, su propia forma de hacer trampa. Si has leído los capítulos anteriores ya tienes el vocabulario. Si no, este capítulo es un buffet libre donde picoteas lo que te interesa.

La idea es la siguiente: para cada género te doy una plantilla mental —componentes, sistemas, plugins, trampas— que puedas copiar y deformar hasta hacerla tuya. Piénsalo como una receta de cocina: aquí tienes la lista de ingredientes, tú decides cuánto echar.

⚠️ Aviso sobre el código: los bloques de este capítulo son pseudocódigo ilustrativo, no código listo para compilar. Omiten uses completos, asumen tipos Component/Resource definidos por el lector y a veces inventan nombres cortos para que la idea se vea limpia. Para fragmentos compilables con imports completos, consulta el proyecto integrador del cap. 14E y los ejemplos oficiales en bevyengine/bevy/examples. Si un bloque tiene use bevy::prelude::*; explícito, ese sí está pensado para compilarse (aunque puede que requiera definir algún tipo auxiliar del propio juego).

El término "género" en videojuegos se consolidó en los 80 cuando Nintendo clasificó sus juegos para la NES en categorías (acción, aventura, RPG, simulación). Antes de eso, un juego era simplemente "un juego". El humilde Platformer de *Donkey Kong* (1981, Nintendo) cabía en una caja de arcade y punto.
- **ECS**: Entity Component System. La forma Bevy de organizar un juego: datos (componentes) separados de comportamiento (sistemas).
- **Schedule**: el orden en que Bevy ejecuta sistemas. `Update` corre cada frame, `FixedUpdate` corre a paso fijo, `PostUpdate` va al final.
- **Plugin externo**: un crate de Rust que añade funcionalidad a Bevy. Como un *mod* de Bethesda, pero con versionado semver.

🎯 Patrón del capítulo: "Plantilla de género"

Todos los apartados siguen la misma estructura, para que puedas saltar al que te interese y leerlo en 5 minutos. El patrón es:

  1. Juegos de referencia para anclar la imaginación.
  2. Filosofía en una frase — qué quiere sentir el jugador.
  3. Tabla de componentes — el vocabulario del género.
  4. Sistemas y schedule — la coreografía.
  5. Plugins recomendados — la caja de herramientas.
  6. Trampa típica — la metedura de pata que todos cometemos.
  7. Mini-grafo — ASCII art de cómo se conectan los plugins.
  8. Glosario + trivia del género.
La típica: copiar la tabla de componentes al pie de la letra y acabar con 30 componentes por entidad. La mayoría de juegos reales usan 5-8 componentes por entidad *en cualquier momento*. El resto son datos del nivel, recursos globales o cosas que se activan con un flag.

1. Platformer / Metroidvania

Juegos de referencia: Hollow Knight (2017, Team Cherry), Celeste (2018, Maddy Makes Games), Metroid (1986, Nintendo).

Filosofía del género: el mundo es tu parque de juegos, y volver a una zona siendo más fuerte es la recompensa.

Componentes típicos

ComponenteFunciónEjemplo
TransformPosición XYZsiempre presente
Velocitydx, dy por segundoVec2
Gravityaceleración YVec2 con valor negativo
Groundedbool, ¿toca suelo?flag de estado
CoyoteTime(Timer)perdón tras dejar sueloTimer
JumpBuffer(Timer)input "guardado"Timer
WallSlide¿resbala por pared?bool + dirección
Dashdash en cooldownTimer + dirección
Healthvida del jugadori32
DamageFlashsprite parpadeaTimer
Collidercaja de colisiónRect
Solidpared / suelotag
OneWayplataforma atravesabletag
Checkpointpunto de guardadoenum de zonas
Ability { dash, double_jump, wall_jump }flags de habilidadesbitset
AnimationStatesprite actualenum
CameraTargetsuavizado de cámarabool
Collectiblepickupenum (corazón, geo…)
EnemyAItipo de IAenum
RoomIdzona actualu32

Sistemas y schedule

FixedUpdate
  ├─ apply_gravity
  ├─ apply_velocity
  └─ check_grounded

Update
  ├─ read_input
  ├─ jump_system
  ├─ dash_system
  ├─ coyote_time_system
  ├─ jump_buffer_system
  └─ wall_slide_system

PostUpdate
  ├─ resolve_collisions
  ├─ update_camera
  └─ update_animation

Plugins externos recomendados

Trampa típica

El salto que se siente mal. Suele ser: gravedad constante mal calibrada, sin coyote time ni jump buffer. La solución es 5 frames de coyote + 5 frames de buffer. Celeste popularizó esto. Hollow Knight lo refinó al milímetro.

Mini-grafo de plugins

            ┌────────────────────────┐
            │      leafwing-input    │
            └───────────┬────────────┘
                        │ ActionState
                        ▼
   ┌────────────────────────────────┐
   │         mi_juego::Player       │
   └────────────┬───────────────────┘
                │
                ▼
   ┌────────────────────────────────┐
   │         bevy_rapier2d          │◀─┐
   │  (colliders, joints, queries)  │  │
   └────────────┬───────────────────┘  │
                │ CollisionEvent       │
                ▼                       │
   ┌────────────────────────────────┐  │
   │    mi_juego::DamageSystem      │  │
   └────────────┬───────────────────┘  │
                │ HealthChanged         │
                ▼                       │
   ┌────────────────────────────────┐  │
   │       bevy_ecs_tilemap         │  │
   │  (sólidos, one-way, spikes)    │──┘
   └────────────┬───────────────────┘
                │ render
                ▼
            [ Pantalla ]
- **Coyote time**: frames de perdón tras dejar el suelo, como si el coyote de los dibujos animados te dejara saltar un pelín después.
- **Jump buffer**: input "guardado" unos frames, por si pulsas saltar 2 frames antes de aterrizar.
- **Wall jump**: salto tras rebotar en una pared, sello del género Metroidvania.
- **Ability gating**: el mundo se abre con habilidades, no con llaves. *Hollow Knight* es el ejemplo canónico.
*Metroid* (1986, Nintendo) inventó el género por accidente: los diseñadores Yoshio Sakamoto y Hirokazu "Hip" Tanaka escondieron pasadizos tras puertas que solo se abrían con ciertas habilidades, simplemente porque la NES tenía poca memoria y reciclaban tiles. De necesidad técnica nació la mejor idea del género.

2. Roguelike / Roguelite

Juegos de referencia: Hades (2020, Supergiant Games), Dead Cells (2018, Motion Twin), Spelunky (2008, Mossmouth).

Filosofía del género: morir es parte del juego, no un castigo. Lo importante es la historia que cuentas mientras mueres.

Componentes típicos

ComponenteFunción
Transformposición
Healthvida actual / máxima
RunStats { kills, time, gold }contadores del run
Weapon { base_dmg, range, type }arma equipada
Affixmodificador del arma (enum: Burning, Knockback…)
Floor(u8)profundidad del run
Seed(u64)semilla del RNG para reproducibilidad
ItemPickupobjeto cogido en run
Blessingdon de un dios (Hades-style)
MetaProgressmoneda permanente entre runs
RoomClearedtag, sala actual vacía
Trappinchos, fuego…
EnemyKindenum, qué IA tiene
ProjectileLifetime(Timer)vida del proyectil
Telegraph(Timer)aviso antes de atacar
LootTablequé dropea al morir
BoonScreenUI: elegir don
DeathCauseenum, "morir por qué"
PermanentUnlockbitset: qué has desbloqueado
HeatLeveldificultad opcional

Sistemas y schedule

FixedUpdate
  ├─ move_enemies
  ├─ check_projectile_lifetimes
  └─ check_trap_telegraphs

Update
  ├─ read_input
  ├─ player_attack
  ├─ spawn_next_room (OnEnter Room)
  ├─ room_cleared_check
  └─ rng_system (reproducir seeds)

PostUpdate
  ├─ ui_health_bar
  ├─ ui_room_transition
  └─ save_meta_progress

Plugins externos recomendados

Trampa típica

Respawnear entidades sin despawnear las anteriores. El clásico bug de roguelike: generas 50 enemigos por sala y "olvidas" matarlos. La solución es siempre despawnear con descendientes al cambiar de sala. En Bevy 0.18/0.19, commands.entity(e).despawn() ya elimina por defecto a los descendientes vinculados vía relaciones como ChildOf/Children; si necesitas un control más fino, usa .despawn_descendants() para los hijos y .despawn() para la propia entidad. Ojo: despawn_recursive() ya no existe en 0.18/0.19 — fue reemplazado por este comportamiento por defecto de despawn(). O usar With<RoomCleared> y un sistema de cleanup.

// Patrón "cámara burbuja": todo lo que sale del room actual muere.
// En Bevy 0.18/0.19 commands.entity(e).despawn() YA elimina descendientes
// vinculados con la relación ChildOf/Children. NO existe "despawn_recursive()".
fn despawn_outside_room(
    mut commands: Commands,
    room: Res<CurrentRoom>,
    q: Query<(Entity, &Transform), Without<Persistent>>,
) {
    for (e, t) in &q {
        if !room.contains(t.translation.truncate()) {
            // despawn() ya se lleva por defecto a los descendientes
            // (hijos vía ChildOf/Children). Si solo quieres los hijos:
            // commands.entity(e).despawn_descendants();
            commands.entity(e).despawn();
        }
    }
}

Mini-grafo de plugins

   ┌──────────────────┐
   │   bevy_rand      │
   │  (RNG seeded)    │
   └────────┬─────────┘
            │ GlobalRng
            ▼
   ┌──────────────────┐
   │ mi_juego::Rooms  │──▶ spawn_enemies
   └────────┬─────────┘
            │ Enemy entities
            ▼
   ┌──────────────────┐
   │  bevy_rapier2d   │──▶ CollisionEvent
   └────────┬─────────┘
            │
            ▼
   ┌──────────────────┐
   │ mi_juego::Combat │──▶ DamageEvent
   └────────┬─────────┘
            │
            ▼
   ┌──────────────────┐
   │ bevy_persistent  │──▶ save.json (meta-progreso)
   └──────────────────┘
- **Seed**: número que inicializa el generador aleatorio. Mismo seed = mismo run. Vital para speedruns.
- **Meta-progreso**: lo que persiste entre runs (moneda, habilidades desbloqueadas).
- **Boon / Blessing**: don pasivo u otorgado, popularizado por *Hades* (2020, Supergiant).
- **Heat / Stakes**: nivel de dificultad opcional que aumenta la recompensa.
- **Curse**: lo opuesto a un boon. *Spelunky* lo lleva al extremo: una maldición mata tu run.
*Spelunky* (2008, Derek Yu) usaba la muerte del jugador para generar nuevos *seeds* de nivel. Si te suicidabas en el nivel 1, el nivel 2 era siempre igual. La comunidad descubrió esto a los 6 meses y los speedruns cambiaron para siempre. La semilla del primer nivel, eso sí, era *constante*.

3. Top-down shooter / Twin-stick

Juegos de referencia: Enter the Gungeon (2016, Dodge Roll), Nuclear Throne (2015, Vlambeer), Hotline Miami (2012, Dennaton Games).

Filosofía del género: no esconderse, no negociar. Mover el stick, apretar el otro stick. La muerte es un frame.

Componentes típicos

ComponenteFunción
Transformposición
VelocityVec2
AimDirectionhacia donde apunta el stick derecho
FireRate(Timer)cadencia de disparo
Ammo { current, max, reload }balas y recarga
ReloadTimertiempo hasta próxima bala
Weapon { kind, damage, knockback }enum + datos
Bulletproyectil (tag + velocidad)
BulletBounce(u8)rebotes restantes
Kickbackretroceso visual del arma
Crosshairsprite del punto de mira
MuzzleFlash(Timer)sprite del fogonazo
Hurtbox / Hitboxcajas de daño
LineOfSightve al jugador?
EnemyKindmelee / ranged / kamikaze
DropTablequé suelta al morir
RoomGatepuerta que abre al matar a todos
ScreenShaketrauma de cámara
DeathExplosionpartículas al morir
Pickup { hp, ammo, key }items en suelo

Sistemas y schedule

FixedUpdate
  ├─ apply_velocity
  ├─ move_projectiles
  └─ check_firerate_timers

Update
  ├─ read_sticks (input)
  ├─ update_aim_direction
  ├─ player_shoot
  ├─ enemy_ai (pathfind_to_player)
  └─ check_room_clear

PostUpdate
  ├─ spawn_muzzle_flash
  ├─ update_screen_shake
  └─ update_camera

Plugins externos recomendados

Trampa típica

Allocación de balas por frame. La madre de todas las meteduras de pata. Si en cada frame spawneas una Entity con Bundle, acabas con 10.000 entidades a los 30 segundos. La solución: object pools (bevy_simple_object_pool) o, mejor, componentes Bullet reutilizables desactivados con un marker propio tipo Disabled (¡que no es nativo de Bevy ECS! — defínelo tú con #[derive(Component)] struct Disabled;) o, si solo quieres ocultar el render, usando Visibility::Hidden.

// Patrón "desactivar" en vez de "despawnear".
// IMPORTANTE: "Disabled" NO es un componente nativo de Bevy ECS.
// Es un marker que tú defines. Lo defines una vez y lo insertas/quitas.
// Si el objetivo es SOLO ocultar el render, usa Visibility::Hidden.

use bevy::prelude::*;

// Marker de "inactivo" definido por el usuario (no nativo).
#[derive(Component)]
pub struct Disabled;

// Lleva el tiempo de vida del proyectil.
#[derive(Component)]
pub struct BulletLifetime(pub Timer);

// Consultas con filtros:
//   - With<Bullet> Without<Disabled> → balas activas
//   - With<Bullet> With<Disabled>   → pool reciclable
fn recycle_bullets(
    mut commands: Commands,
    time: Res<Time>,  // <- imprescindible; si no, no compila.
    mut q: Query<(Entity, &mut Transform, &mut BulletLifetime),
                (With<Bullet>, Without<Disabled>)>,
) {
    for (e, mut t, mut life) in &mut q {
        life.0.tick(time.delta());
        if life.0.finished() || t.translation.y > 1000.0 {
            // Desactivar en vez de despawnear.
            commands.entity(e).insert(Disabled);
        }
    }
}

Mini-grafo de plugins

   ┌────────────────────┐
   │ leafwing-input     │──▶ DualAxis<Move> / DualAxis<Aim>
   └─────────┬──────────┘
             │
             ▼
   ┌────────────────────┐
   │ mi_juego::TwinStick│
   └─────────┬──────────┘
             │ WantsToShoot
             ▼
   ┌────────────────────┐
   │  bevy_rapier2d     │──▶ Balas como KinematicVelocity
   └─────────┬──────────┘
             │ CollisionEvent
             ▼
   ┌────────────────────┐
   │ mi_juego::HitReact │──▶ DamageEvent
   └─────────┬──────────┘
             │
             ▼
   ┌────────────────────┐
   │  bevy_hanabi       │──▶ partículas
   └────────────────────┘
- **Twin-stick**: dos sticks, uno mueve, otro dispara. *Robotron 2084* (1982, Williams) lo inventó.
- **Kickback**: retroceso del arma al disparar, no del personaje.
- **Fire rate**: balas por segundo. *Enter the Gungeon* permite builds de 30 balas/seg.
- **Room clear**: matar a todos los enemigos para abrir la siguiente puerta.
- **Screen shake**: agitar la cámara al recibir daño o disparar. Hanselman lo elevó a arte.
*Hotline Miami* (2012, Dennaton Games) regenera toda la planta de enemigos al morir, en milisegundos. El truco técnico: el nivel es un array plano, no se serializa; se re-instancia desde una semilla. Matar a un enemigo con una maceta se siente único; reintentar la sala se siente instantáneo. Esa es la magia ECS bien aplicada.

4. Bullet hell / Curtain fire

Juegos de referencia: Touhou Project (1997, ZUN), Ikaruga (2001, Treasure), Undertale (2015, Toby Fox, en algunos patrones).

Filosofía del género: si ves un hueco, pasa. Si no lo ves, espera. La densidad de balas ES el contenido.

Componentes típicos

ComponenteFunción
Transformposición
Velocityvector de movimiento
AccelerationVec2 para balas curvas
Lifetime(Timer)cuándo muere la bala
BulletShape { kind, size, color }visual de la bala
BulletPatternenum: Aimed, Spiral, Ring, Wave
Spawner { rate, angle, count }generador continuo
EmitterOffsetdesde dónde sale (mano del boss)
PolarCoordinate { r, theta, dr, dtheta }patrón polar
Playertag
Hitbox(Rect)pequeño, 2-4 px
Graze(Timer)cerca sin tocar
Bombclear de pantalla
PowerLevelnivel del arma del jugador
BossPhaseenum: fase actual
PhaseTransition(Timer)cambio de fase
DialogueBoxtexto durante patrón
ScoreMultiplierx1, x2, x4, x8…
Rankdificultad oculta
BackgroundLayerparallax del fondo

Sistemas y schedule

FixedUpdate
  ├─ emit_bullets (por cada Spawner)
  ├─ integrate_bullets
  ├─ check_lifetimes
  └─ check_graze

Update
  ├─ player_input
  ├─ pattern_ai (boss decides next pattern)
  ├─ check_phase_transitions
  └─ update_dialogue

PostUpdate
  ├─ render_bullets
  ├─ update_score_display
  └─ parallax_background

Plugins externos recomendados

Trampa típica

Allocación de proyectiles cada frame (otra vez). En bullet hell esto se multiplica por 100. Un patrón "anillo de 50 balas cada 0.1s" son 500 entidades por segundo. La solución es GPU instancing o, en CPU, bevy_simple_object_pool. Si tu juego va a 30 fps con 200 balas en pantalla, algo va mal.

// Spawner eficiente: pre-allocar balas y reusar.
// NOTA: "BulletPool" NO es un tipo nativo de Bevy. Es un patrón
// de object pool definido por el usuario (un Resource que guarda un
// Vec<Entity> con entidades pre-spawneadas). Lo defines tú, así:
//
//   #[derive(Resource)]
//   pub struct BulletPool { free: Vec<Entity> }
//   impl BulletPool {
//       pub fn acquire(&mut self) -> Option<Entity> { self.free.pop() }
//       pub fn release(&mut self, e: Entity) { self.free.push(e) }
//   }
//
// Crate opcional: "bevy_simple_object_pool" lo resuelve por ti.
fn emit_ring_pattern(
    mut commands: Commands,
    mut pool: ResMut<BulletPool>,  // Resource definido por ti, NO nativo de Bevy.
    spawner: Query<(&Transform, &RingPattern)>,
) {
    for (t, pattern) in &spawner {
        for i in 0..pattern.count {
            let angle = i as f32 * std::f32::consts::TAU / pattern.count as f32;
            let dir = Vec2::new(angle.cos(), angle.sin());
            if let Some(entity) = pool.acquire() {
                commands.entity(entity)
                    .insert(Transform::from_translation(t.translation))
                    .insert(Velocity(dir * pattern.speed))
                    .remove::<Disabled>();  // Marker propio; ver sección anterior.
            }
        }
    }
}

Mini-grafo de plugins

   ┌─────────────────────┐
   │  mi_juego::Spawners │
   └──────────┬──────────┘
              │ spawn_ring / spawn_spiral
              ▼
   ┌─────────────────────┐
   │   BulletPool (Res)  │──▶ recicla entities
   └──────────┬──────────┘
              │ entidades activas
              ▼
   ┌─────────────────────┐
   │    bevy_hanabi      │──▶ GPU instancing
   └──────────┬──────────┘
              │
              ▼
   ┌─────────────────────┐
   │    bevy_rapier2d    │──▶ circle vs circle
   └─────────────────────┘
- **Curtain fire**: cortina de balas,*dificultad* hecha de volumen de proyectiles.
- **Graze**: rozar una bala sin tocarla. *Touhou* lo introdujo y da puntos extra.
- **Bomb**: botón de "reset" que limpia la pantalla. Recurso escaso, vital.
- **Polar coordinate**: radio y ángulo en vez de X/Y. Patrones circulares fáciles de escribir.
- **Rank**: dificultad oculta que sube si te va bien. *Ikaruga* (2001, Treasure) lo popularizó.
*Touhou Project* (1997, ZUN / Team Shanghai Alice) empezó como un juego para PC-98 que ZUN programaba él solo en su tiempo libre mientras trabajaba en una empresa de software. La primera entrega, *Highly Responsive to Prayers*, era un *shoot 'em up* que también tenía secciones de resolución de puzzles — y aun así se convirtió en el juego más vendido en Comiket durante una década. El secreto: cada bala tiene una hitbox de 1 píxel.

5. Beat-em-up / Fighting

Juegos de referencia: Streets of Rage (1991, Sega), River City Girls (2019, WayForward), Streets of Rage 4 (2020, Lizardcube / Guard Crush / DotEmu).

Filosofía del género: puños, pies, y a veces una tubería. Hasta 4 amigos a la vez, en la misma pantalla.

Componentes típicos

ComponenteFunción
Transformposición
Velocitymovimiento
Facing(Direction)izquierda / derecha
Fightertag
Healthvida
ComboCountercontador de golpes encadenados
Attack { kind, frame, hitbox }estado de ataque
Hitstun(Timer)aturdido, no puede actuar
Blockstun(Timer)bloqueando
BlockHoldsigue bloqueando
Throwestado de agarre
Weapontag: tiene objeto cogido
ThrowInvulnintocable al ser soltado
AIKindgoon, mini-boss, boss
Spawnergenera enemigos por oleada
Wave(u8)número de oleada actual
ArenaBoundslímites laterales
Pickuparma en suelo
Score { current, multiplier }puntos estilo arcade
LivesRemainingcontinues

Sistemas y schedule

FixedUpdate
  ├─ apply_velocity
  ├─ apply_hitstun
  └─ check_arena_bounds

Update
  ├─ read_input_per_player (4 ActionStates)
  ├─ combo_state_machine
  ├─ fighter_ai
  ├─ spawn_wave
  └─ check_wave_clear

PostUpdate
  ├─ update_health_bars
  ├─ update_score_display
  └─ update_lives_display

Plugins externos recomendados

Trampa típica

Locks de input durante animaciones. El error clásico: pones if attacking { return } en input, y el jugador se siente rígido. La solución: input buffering. El jugador pulsa el siguiente ataque durante la animación, y el sistema lo ejecuta al terminar. Es el alma de Streets of Rage 4.

// Input buffer para encadenar golpes. Fíjate en el "time: Res<Time>":
// sin ese parámetro NO compila, porque llamamos a "time.delta()".
fn buffer_input(
    mut commands: Commands,
    time: Res<Time>,  // <- OBLIGATORIO para hacer tick al Timer.
    input: Query<&ActionState<FighterAction>>,
    mut q: Query<(Entity, &mut ComboBuffer)>,
) {
    for (e, mut buf) in &mut q {
        if input.single().just_pressed(&FighterAction::Attack) {
            buf.pending = Some(FighterAction::Attack);
            buf.timer = Timer::from_seconds(0.15, TimerMode::Once);
        }
        buf.timer.tick(time.delta());
    }
}

Mini-grafo de plugins

   ┌──────────────────────┐
   │ bevy_gilrs           │──▶ Gamepad(0..3)
   └──────────┬───────────┘
              │
              ▼
   ┌──────────────────────┐
   │ leafwing-input       │──▶ ActionState<FA> x4
   └──────────┬───────────┘
              │
              ▼
   ┌──────────────────────┐
   │ mi_juego::ComboFSM   │──▶ AttackEvent
   └──────────┬───────────┘
              │
              ▼
   ┌──────────────────────┐
   │ bevy_rapier2d        │──▶ Hitbox vs Hurtbox
   └──────────┬───────────┘
              │
              ▼
   ┌──────────────────────┐
   │ mi_juego::Spawner    │──▶ oleada de 8 goons
   └──────────────────────┘
- **Combo**: cadena de golpes encadenados. El medidor sube hasta 99 en muchos beat-em-ups.
- **Hitstun**: tiempo en que un enemigo no puede actuar tras recibir un golpe.
- **Input buffer**: input "guardado" durante la animación actual, *como* el jump buffer del platformer.
- **Throw invuln**: invulnerabilidad al ser lanzado. Evita combos infinitos.
- **Wave / Spawner**: oleada de enemigos. *Streets of Rage* las escala en dificultad.
*Streets of Rage* (1991, Sega) tenía un bug famoso: en la pantalla del segundo acto, si dos enemigos te agarraban a la vez, podías ser lanzado *fuera de la pantalla* y morir instantáneamente. Sega lo dejó en el cartucho por "característica". Treinta años después, los speedrunners lo explotan.

6. Puzzle

Juegos de referencia: Baba Is You (2019, Hempuli), Sokoban (1982, Thinking Rabbit), The Witness (2016, Jonathan Blow, en su versión 2D).

Filosofía del género: pensar > actuar. El botón está ahí, pero el jugador decide si y cuándo.

Componentes típicos

ComponenteFunción
Tile { kind, pos }celda del tablero
Pushabletag: el jugador lo empuja
Sinkabledesaparece al pisarlo
Wintag: bloque que activa victoria
RuleBlock { noun, operator, value }Baba is You style
Rulesrecurso con todas las reglas activas
Playertag
Move(Timer)cooldown de movimiento
UndoStackhistorial para deshacer
Levelrecurso: id del nivel
MovesCountercuántos movimientos llevas
Parmovimientos mínimos
Hintpista opcional
LockedTilenecesita X pasos antes
PressurePlateactiva algo al pisarlo
Conveyorempuja en una dirección
Pickup { kind, target }cubo, llave, etc.
Doorabre con llave / interruptor
Animatorpara tiles animados
ParticleFXcelebración al ganar

Sistemas y schedule

FixedUpdate
  ├─ read_input
  ├─ push_player_or_box
  └─ check_win_condition

Update
  ├─ evaluate_rules (Baba is You)
  ├─ undo_system
  └─ animate_tiles

PostUpdate
  ├─ update_move_counter
  └─ render_win_banner

Plugins externos recomendados

Trampa típica

Animación bloqueando lógica. En puzzles, si la animación ocupa al jugador, el input se siente lento. Solución: el input se procesa después de la animación, o la animación es tan rápida (50ms) que no molesta. Baba Is You hace que cada movimiento tarde lo que dura un latido de corazón.

// Patrón "input queue" para puzzles
fn process_input_queue(
    mut commands: Commands,
    mut q: Query<(Entity, &mut MoveAnimation)>,
    time: Res<Time>,
) {
    for (e, mut anim) in &mut q {
        anim.timer.tick(time.delta());
        if anim.timer.finished() {
            commands.entity(e).remove::<MoveAnimation>();
        }
    }
}

Mini-grafo de plugins

   ┌──────────────────────┐
   │  mi_juego::Input     │
   └──────────┬───────────┘
              │ Move event
              ▼
   ┌──────────────────────┐
   │ mi_juego::PushSolver │──▶ consulta tiles
   └──────────┬───────────┘
              │ UpdateTile
              ▼
   ┌──────────────────────┐
   │ bevy_ecs_tilemap     │──▶ repaint
   └──────────┬───────────┘
              │
              ▼
   ┌──────────────────────┐
   │ mi_juego::WinCheck   │──▶ WinEvent
   └──────────┬───────────┘
              │
              ▼
   ┌──────────────────────┐
   │ bevy_tweening        │──▶ animar victoria
   └──────────────────────┘
- **Tile**: celda discreta del tablero. Sokoban usa 32×32 px, *Baba Is You* usa 64×64.
- **Pushable**: objeto que el jugador mueve, *el* mecanismo central de Sokoban.
- **Win condition**: regla que activa la victoria. En *Baba Is You* la regla misma se reescribe.
- **Undo**: deshacer el último movimiento. Vital en puzzles, la mayoría lo ofrece (N+1).
- **Par**: número mínimo de movimientos para resolver el nivel. Métrica de speedrun.
*Baba Is You* (2019, Hempuli / Arvi Teikari) nació como prototipo para una game jam finlandesa en 2017. La idea central — *las reglas del juego son bloques que puedes mover* — no se le había ocurrido a nadie en 40 años de puzzles. El juego fue nominado a varios GOTY, algo inaudito para un puzzle minimalista de un solo desarrollador.

7. Sim / Tycoon / Stardew-like

Juegos de referencia: Stardew Valley (2016, ConcernedApe), Factorio (2020, Wube Software, en 2D), Harvest Moon (1996, Natsume).

Filosofía del género: el progreso es lento y a la vez infinito. Hoy cosecho nabos; mañana fundo una empresa.

Componentes típicos

ComponenteFunción
Tilecelda del mundo
Crop { seed, growth_stage, watered }planta
Tile { tilled, watered, fertilized }estado de la tierra
Tool { kind, level, durability }azada, regadera, hacha
Inventory { items, slots }recursos
Energy(u16)energía del día
TimeOfDayrecurso global: 6:00 a 24:00
DayCounterdía actual
Seasonprimavera, verano…
Weathersoleado, lluvia
NPCaldeano con Schedule
ScheduleIA de NPC por hora
Friendship(i16)puntos con un NPC
Shopabre a X hora
Mailboxcartas que llegan
Recipequé se puede craftear
Buildingestructuras
Animalgallina, vaca, oveja
Productoutput diario
Moneyoro del jugador

Sistemas y schedule

FixedUpdate
  ├─ grow_crops
  ├─ produce_animals
  └─ regen_energy_during_sleep

Update
  ├─ advance_clock
  ├─ npc_schedule
  ├─ player_input_tool_use
  └─ check_shop_hours

PostUpdate
  ├─ update_hud
  ├─ update_inventory_ui
  └─ save_on_sleep

Plugins externos recomendados

Trampa típica

Guardar a cada frame. En sims, los datos crecen rápido: posición de 200 cultivos, 50 NPCs, 1000 tiles. Guardar a 60 fps destruye SSDs. La solución: guardar al final del día o al dormir, y serializar solo los cambios incrementales.

// Guardar solo al dormir.
//
// OJO: "world.serialize_dyn()" NO existe. Bevy no expone una API
// genérica de "serialízame el mundo entero". Lo que sí existe:
//   1) Un Resource propio (SaveSlot) derive(Serialize, Deserialize) + serde
//      para serializar a JSON. Es lo más simple y portable.
//   2) bevy_scene / DynamicScene: para serializar entidades y
//      componentes REFlejados (con #[reflect(Serialize)]). Útil si quieres
//      snapshotear entidades concretas, pero requiere registrar tipos.
//   3) Crates de la comunidad como "bevy_persistent" o "bevy_save".
//
// Importante: un sistema NO puede tomar &World Y EventReader a la vez
// (ambos son SystemParam mutuamente excluyentes). Por eso pasamos el Resource.

use bevy::prelude::*;
use serde::{Serialize, Deserialize};

// Estructura de guardado explícita: serializa solo lo imprescindible.
#[derive(Resource, Serialize, Deserialize, Default)]
pub struct SaveSlot {
    pub day: u32,
    pub season: String,
    pub money: i64,
    pub inventory: Vec<String>,
    // ... añade aquí lo que quieras persistir.
}

#[derive(Event)]
pub struct SleepEvent;

fn save_on_sleep(
    mut ev_sleep: EventReader<SleepEvent>,
    save: Res<SaveSlot>,
) {
    for _ in ev_sleep.read() {
        // Serializamos el Resource de guardado UNA vez al dormir.
        // No el mundo entero: solo lo que el juego decide persistir.
        match serde_json::to_string_pretty(save.as_ref()) {
            Ok(json) => {
                let _ = std::fs::write("save.json", json);
            }
            Err(e) => eprintln!("No se pudo guardar: {e}"),
        }
    }
}

// Alternativa con bevy_scene (si necesitas snapshot de entidades):
//   use bevy::scene::DynamicSceneBuilder;
//   let scene = DynamicSceneBuilder::from_world(world)
//       .extract_entities(|q| q.iter().filter(|e| /* … */ ))
//       .build();
//   let serialized = scene.serialize(&type_registry).unwrap();
// Pero ojo: esto requiere &World y NO se puede mezclar con EventReader
// en la misma función. Tendrías que disparar un comando/otro sistema.

TODO editorial: verificar el tipo exacto de DynamicSceneBuilder y el método serialize en 0.19 (las APIs de bevy_scene cambian entre versiones; comprobar contra docs.rs/bevy/0.19.0/bevy/scene/ antes de publicar).

Mini-grafo de plugins

   ┌──────────────────────────┐
   │   mi_juego::Clock        │──▶ TimeOfDay, Day, Season
   └─────────────┬────────────┘
                 │
                 ▼
   ┌──────────────────────────┐
   │ mi_juego::CropSystem     │──▶ growth_stage++
   └─────────────┬────────────┘
                 │
                 ▼
   ┌──────────────────────────┐
   │ mi_juego::NPCSchedule    │──▶ NPC positions
   └─────────────┬────────────┘
                 │
                 ▼
   ┌──────────────────────────┐
   │ bevy_ecs_tilemap         │──▶ repaint
   └─────────────┬────────────┘
                 │
                 ▼
   ┌──────────────────────────┐
   │ bevy_persistent          │──▶ save.json (al dormir)
   └──────────────────────────┘
- **Time of day**: reloj interno. En *Stardew*, 1 hora = ~50 segundos reales.
- **Crop growth**: cada cultivo tiene X días hasta madurar. Las estaciones los matan si no se cosecha.
- **Friendship**: puntos con cada NPC, suben al hablar, regalar, etc.
- **Energy**: recurso que limita acciones por día. Duermes para regenerar.
- **Persistence**: guardar el mundo. Vital, *porque* el progreso es lento y perderlo duele.
*Stardew Valley* (2016, ConcernedApe / Eric Barone) lo programó **una sola persona** en 5 años, encima de un trabajo a tiempo completo. Está escrito en C# con MonoGame, no en Bevy. La moraleja: si tu engine te obliga a escribir 10.000 líneas de boilerplate, prueba otra cosa. (Bevy está del lado bueno de la historia.)

8. RTS-lite 2D / Tower defense

Juegos de referencia: They Are Billions (2019, Numantian Games), Kingdom (2015, Noio / Raw Fury), Dungeon Keeper (1997, Bullfrog, en 2D).

Filosofía del género: cada recurso es un latido. Si paras, pierdes.

Componentes típicos

ComponenteFunción
Building { kind, level, hp }estructura
BuildQueuequé se está construyendo
Workeraldeano que trabaja
WorkerTaskenum: Idle, Build, Gather, Repair
ResourceDeposit { gold, wood, stone }almacenamiento
Tower { range, dmg, fire_rate }defensa
TowerTargetqué enemigo está atacando
Enemyunidad enemiga
PathWaypointsiguiente punto del camino
Wave(u8)número de oleada
WaveSpawnergenera oleada
MapFogniebla de guerra
Visiblerevelado en niebla
CommandPostradio de construcción
PopulationCaplímite de unidades
Economyresource global: ingresos
Researchárbol de mejoras
BuildRadiusdesde dónde construir
Destructibletag
AlertStateCalm, UnderAttack

Sistemas y schedule

FixedUpdate
  ├─ build_progress
  ├─ resource_income
  └─ wave_spawn

Update
  ├─ player_input
  ├─ worker_ai
  ├─ tower_ai
  ├─ enemy_pathfind
  └─ check_wave_clear

PostUpdate
  ├─ update_fog_of_war
  ├─ update_minimap
  └─ update_economy_hud

Plugins externos recomendados

Trampa típica

Pathfinding recalculado cada frame para 500 enemigos. Catastrófico. La solución es flow fields (un solo campo de costos para todos) o navmesh precomputado. bevy_flowfield_tiles_plugin hace exactamente esto.

// Flow field: UNA búsqueda A* para todos los enemigos
fn recalc_flowfield(
    mut flow: ResMut<FlowField>,
    towers: Query<&Transform, With<Tower>>,
    base: Query<&Transform, With<CommandPost>>,
) {
    flow.calculate(base.single().translation.truncate(), |cell| {
        towers.iter().any(|t| t.translation.truncate().distance(cell) < 64.0)
    });
}

Mini-grafo de plugins

   ┌──────────────────────────┐
   │   mi_juego::Economy      │──▶ gold, wood
   └─────────────┬────────────┘
                 │
                 ▼
   ┌──────────────────────────┐
   │ mi_juego::BuildSystem    │──▶ spawn Building
   └─────────────┬────────────┘
                 │
                 ▼
   ┌──────────────────────────┐
   │ bevy_flowfield_tiles     │──▶ pathfinding masivo
   └─────────────┬────────────┘
                 │
                 ▼
   ┌──────────────────────────┐
   │ mi_juego::WaveSpawner    │──▶ Enemy entities
   └─────────────┬────────────┘
                 │
                 ▼
   ┌──────────────────────────┐
   │ bevy_rts_camera          │──▶ pan/zoom
   └──────────────────────────┘
- **Flow field**: un campo de vectores que dice, para cada celda, hacia dónde ir. Mil veces más eficiente que A* por unidad.
- **Fog of war**: niebla que oculta zonas no exploradas. Visión = información.
- **Wave**: oleada de enemigos. Se intensifica con el tiempo.
- **Economy**: ingresos pasivos. *Kingdom* lo lleva al mínimo: 1 moneda/día por campesino.
- **Build queue**: cola de construcción. Permite planificar.
*Kingdom* (2015, Noio / Raw Fury) es un tower defense donde *no* puedes saltar. Tu personaje es el rey, y solo se mueve a izquierda y derecha. Los campesinos pagan monedas automáticamente. La economía entera son dos animaciones: una persona cortando un árbol, una persona cavando oro. Esa minimalidad se siente *densa*, no vacía.

9. Visual novel / Point & click

Juegos de referencia: Disco Elysium (2019, ZA/UM), Monkey Island (1990, LucasArts), Ace Attorney (2001, Capcom).

Filosofía del género: la pantalla es un libro. El clic es pasar la página. A veces, decidir.

Componentes típicos

ComponenteFunción
DialogueLine { speaker, text, voice }una línea de diálogo
DialogueTreegrafo de conversación
Speakerquién habla
Portrait { expression, side }cara del personaje
Backgroundimagen del fondo
Choice { text, leads_to }opción del jugador
Inventoryobjetos llevados
Hotspotzona clicable del fondo
Cursoraspecto del ratón
LookCursor / UseCursor / TalkCursorverbos de point & click
Cutscenesecuencia no interactiva
SaveSlotpartidas guardadas
Flagbool: "has_met_X"
Variableint: "suspicion_level"
Journalregistro de pistas
Thoughtpensamiento interior (Disco Elysium)
SkillCheckdado + bonus vs dificultad
Mapimagen del lugar actual
BGMTrackmúsica con crossfade

Sistemas y schedule

FixedUpdate
  ├─ advance_text_typing
  └─ check_dialogue_advance

Update
  ├─ read_mouse_click
  ├─ resolve_hotspot
  ├─ advance_dialogue
  └─ check_choices

PostUpdate
  ├─ fade_background
  ├─ update_journal_ui
  └─ save_thought_to_history

Plugins externos recomendados

Trampa típica

Texto que aparece letra a letra sin opción de skip rápido. El jugador impaciente odia esperar. La solución: triple clic = salta al final. Disco Elysium lo hace en 2019 y sienta precedente.

// Click counting para skip rápido
fn dialogue_input(
    mut click: EventReader<MouseClick>,
    mut state: ResMut<DialogueState>,
) {
    let clicks_in_frame = click.read().count();
    if clicks_in_frame >= 3 {
        state.skip_to_end();
    } else if clicks_in_frame == 1 {
        state.advance_or_complete();
    }
}

Mini-grafo de plugins

   ┌────────────────────────┐
   │   bevy_yarnspinner     │──▶ YarnRunner
   └────────────┬───────────┘
                │ DialogueNode
                ▼
   ┌────────────────────────┐
   │ mi_juego::ChoiceSystem │──▶ Choice events
   └────────────┬───────────┘
                │
                ▼
   ┌────────────────────────┐
   │ mi_juego::Journal      │──▶ flag update
   └────────────┬───────────┘
                │
                ▼
   ┌────────────────────────┐
   │ bevy_tweening          │──▶ fade background
   └────────────┬───────────┘
                │
                ▼
   ┌────────────────────────┐
   │ bevy_persistent        │──▶ save_slot_N.json
   └────────────────────────┘
- **Hotspot**: zona clicable de un fondo. *Monkey Island* popularizó el "uso de la banana en la cabeza".
- **Dialogue tree**: árbol de conversación. Las novelas visuales modernas son grafos dirigidos.
- **Skill check**: prueba de habilidad con dado. *Disco Elysium* (2019) lo llevó a la extrema.
- **Flag**: variable booleana para el motor de diálogo. `has_met_inspector = true`.
- **Variable**: variable entera/texto. Útil para contar decisiones o dinero.
*Disco Elysium* (2019, ZA/UM) tiene **1 millón** de palabras escritas. Para ponerlo en contexto: *El Quijote* tiene 380.000. La mayoría de esas palabras son accesibles solo si juegas con una build de "inteligencia" alta, lo que se siente como un *easter egg* deliberado para premiar a los lectores. ZA/UM tardó 5 años en escribir el guion antes de tocar una línea de código.

10. Runner / endless

Juegos de referencia: Canabalt (2009, Adam Saltsman), Vampire Survivors (2022, poncle), Temple Run (2011, Imangi Studios).

Filosofía del género: una mecánica, una pantalla, una muerte. La "trampa" es que la muerte se siente justa.

Componentes típicos

ComponenteFunción
Transformposición
VelocityVec2
Runnertag del jugador
Jump(Timer)cadencia de salto
Slide(Timer)deslizar
WallSliderebota en pared
Obstacletag: choca
ObstacleKindenum: bajo, alto, en pared
Chunkpedazo de nivel pre-generado
ChunkPoolpool de chunks
DistanceRunrecurso: metros avanzados
Speedf32 que crece con el tiempo
CameraShaketrauma
Coinpickup
Powerupinvencibilidad, imán
HighScoreresource
Inputun botón: saltar
Particleal chocar
AudioBeatritmo de la música
DeathCountcuántas veces has muerto

Sistemas y schedule

FixedUpdate
  ├─ advance_chunks
  ├─ recycle_chunks
  └─ increase_speed

Update
  ├─ tap_to_jump
  ├─ check_collisions
  └─ spawn_obstacles

PostUpdate
  ├─ update_distance_counter
  ├─ update_camera
  └─ render_particles

Plugins externos recomendados

Trampa típica

Generar chunks con rand sin seed. El jugador muere en el segundo 12 dos veces y ve la misma configuración: se siente roto. La solución: dificultad determinista por distancia, no por RNG. Los obstáculos aparecen a X metros, no "aleatoriamente".

// Dificultad determinista por distancia
fn spawn_obstacles(
    mut commands: Commands,
    dist: Res<DistanceRun>,
    time: Res<Time>,
) {
    let t = dist.0;
    if t > 100.0 && (t % 50.0) < 1.0 {
        spawn_obstacle(&mut commands, ObstacleKind::Low);
    }
    if t > 300.0 && (t % 30.0) < 1.0 {
        spawn_obstacle(&mut commands, ObstacleKind::Wall);
    }
}

Mini-grafo de plugins

   ┌────────────────────────┐
   │  mi_juego::ChunkSystem │──▶ pool de chunks
   └────────────┬───────────┘
                │ Chunk entities
                ▼
   ┌────────────────────────┐
   │  bevy_rapier2d         │──▶ CollisionEvent
   └────────────┬───────────┘
                │
                ▼
   ┌────────────────────────┐
   │  mi_juego::DeathSystem │──▶ DeathEvent
   └────────────┬───────────┘
                │
                ▼
   ┌────────────────────────┐
   │  bevy_persistent       │──▶ highscore.json
   └────────────────────────┘
- **Chunk**: pedazo de nivel pre-generado que se recicla. *Temple Run* popularizó esto en móvil.
- **Speed ramp**: la velocidad aumenta con la distancia. El enemigo es el tiempo.
- **One button**: un solo botón para jugar. *Canabalt* lo inventó; *Vampire Survivors* lo simplificó aún más (sin botón).
- **Pool**: pre-asignación de entidades. En runners, indispensable.
- **Procedural seeds**: a veces se usan seeds para que cada partida sea única.
*Vampire Survivors* (2022, poncle / Luca Galante) es, técnicamente, un runner en el que el personaje *no corre*: se queda quieto, y los enemigos vienen en oleadas infinitas. La "carrera" es contra el reloj de los 30 minutos. Salió del acceso anticipado con 0 marketing y 5 dólares de precio; vendió 3 millones de copias en su primer año. La calidad de los 7 minutos de tutorial es directamente proporcional al éxito: te enseñan *todo* jugando.

Lo que vimos

Diez géneros, diez arquitecturas distintas pero con un patrón común: componentes pequeños, sistemas con schedules claros, plugins externos como bloques LEGO. La tabla resumen:

GéneroPlugin estrellaTrampa top
Platformerbevy_rapier2dsin coyote time
Roguelikebevy_rand + bevy_persistentno limpiar salas
Twin-stickbevy_rapier2d + poolallocar balas por frame
Bullet hellbevy_hanabi + poolmiles de entidades
Beat-em-upleafwing-inputsin input buffer
Puzzlebevy_ecs_tilemapanimación bloquea input
Sim / Tycoonbevy_persistentguardar a 60 fps
RTS-litebevy_flowfield_tilesA* por enemigo
VN / Point&Clickbevy_yarnspinnersin skip rápido
Runnerpool de chunksRNG no determinista
La generalización tramposa: creer que "todos los juegos 2D son un platformer con extras". Cada género tiene su propio *ciclo* de feedback. Un puzzle es un bucle de pensar-actuar-evaluar. Un bullet hell es un bucle de dodge-shoot-clear. Forzar la arquitectura de un género sobre otro es la receta para sentir que "algo no encaja".

En el siguiente

En el siguiente capítulo vamos a meter las manos en un proyecto integrador: un metroidvania-lite que mezcla platformer, bullet hell y roguelite en un solo bundle. Verás cómo los patrones de este catálogo se encajan como piezas de Lego, y por qué Bevy brilla cuando los componentes son pequeños y los sistemas están en su schedule correcto. Spoiler: bevy_rapier2d + bevy_ecs_tilemap + leafwing-input cubrirán el 80% del trabajo.

Nos vemos allí. Y no te olvides de hacer la coyote time, que ya sabes cuál es la respuesta.