— 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 tiposComponent/Resourcedefinidos 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 enbevyengine/bevy/examples. Si un bloque tieneuse 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.
Todos los apartados siguen la misma estructura, para que puedas saltar al que te interese y leerlo en 5 minutos. El patrón es:
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.
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.
| Componente | Función | Ejemplo |
|---|---|---|
Transform | Posición XYZ | siempre presente |
Velocity | dx, dy por segundo | Vec2 |
Gravity | aceleración Y | Vec2 con valor negativo |
Grounded | bool, ¿toca suelo? | flag de estado |
CoyoteTime(Timer) | perdón tras dejar suelo | Timer |
JumpBuffer(Timer) | input "guardado" | Timer |
WallSlide | ¿resbala por pared? | bool + dirección |
Dash | dash en cooldown | Timer + dirección |
Health | vida del jugador | i32 |
DamageFlash | sprite parpadea | Timer |
Collider | caja de colisión | Rect |
Solid | pared / suelo | tag |
OneWay | plataforma atravesable | tag |
Checkpoint | punto de guardado | enum de zonas |
Ability { dash, double_jump, wall_jump } | flags de habilidades | bitset |
AnimationState | sprite actual | enum |
CameraTarget | suavizado de cámara | bool |
Collectible | pickup | enum (corazón, geo…) |
EnemyAI | tipo de IA | enum |
RoomId | zona actual | u32 |
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
bevy_rapier2d — física con detección de colisiones continua.bevy_ecs_tilemap (o bevy_tiled) — mapas por tiles.bevy_kira_audio o bevy_audio — música adaptativa por zona.leafwing-input-manager — input robusto con ActionState.bevy_tweening — interpolación de cámara y muerte del boss.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.
┌────────────────────────┐
│ 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.
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.
| Componente | Función |
|---|---|
Transform | posición |
Health | vida actual / máxima |
RunStats { kills, time, gold } | contadores del run |
Weapon { base_dmg, range, type } | arma equipada |
Affix | modificador del arma (enum: Burning, Knockback…) |
Floor(u8) | profundidad del run |
Seed(u64) | semilla del RNG para reproducibilidad |
ItemPickup | objeto cogido en run |
Blessing | don de un dios (Hades-style) |
MetaProgress | moneda permanente entre runs |
RoomCleared | tag, sala actual vacía |
Trap | pinchos, fuego… |
EnemyKind | enum, qué IA tiene |
ProjectileLifetime(Timer) | vida del proyectil |
Telegraph(Timer) | aviso antes de atacar |
LootTable | qué dropea al morir |
BoonScreen | UI: elegir don |
DeathCause | enum, "morir por qué" |
PermanentUnlock | bitset: qué has desbloqueado |
HeatLevel | dificultad opcional |
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
bevy_rapier2d — colisiones de mobs.bevy_rand — RNG determinista reproducible.serde + bevy_persistent — guardar meta-progreso entre runs.bevy_egui o bevy_ui — menú de elección de dones.bevy_proto o bevy_asset_loader — cargar plantillas de armas/items.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();
}
}
}
┌──────────────────┐
│ 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*.
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.
| Componente | Función |
|---|---|
Transform | posición |
Velocity | Vec2 |
AimDirection | hacia donde apunta el stick derecho |
FireRate(Timer) | cadencia de disparo |
Ammo { current, max, reload } | balas y recarga |
ReloadTimer | tiempo hasta próxima bala |
Weapon { kind, damage, knockback } | enum + datos |
Bullet | proyectil (tag + velocidad) |
BulletBounce(u8) | rebotes restantes |
Kickback | retroceso visual del arma |
Crosshair | sprite del punto de mira |
MuzzleFlash(Timer) | sprite del fogonazo |
Hurtbox / Hitbox | cajas de daño |
LineOfSight | ve al jugador? |
EnemyKind | melee / ranged / kamikaze |
DropTable | qué suelta al morir |
RoomGate | puerta que abre al matar a todos |
ScreenShake | trauma de cámara |
DeathExplosion | partículas al morir |
Pickup { hp, ammo, key } | items en suelo |
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
bevy_rapier2d — el más usado; KinematicVelocityBased para proyectiles.leafwing-input-manager — bindings de doble stick.bevy_hanabi — partículas del fogonazo y muerte.bevy_kira_audio — sonido posicional de disparos.bevy_tweening — screen shake procedural.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);
}
}
}
┌────────────────────┐
│ 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.
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.
| Componente | Función |
|---|---|
Transform | posición |
Velocity | vector de movimiento |
Acceleration | Vec2 para balas curvas |
Lifetime(Timer) | cuándo muere la bala |
BulletShape { kind, size, color } | visual de la bala |
BulletPattern | enum: Aimed, Spiral, Ring, Wave |
Spawner { rate, angle, count } | generador continuo |
EmitterOffset | desde dónde sale (mano del boss) |
PolarCoordinate { r, theta, dr, dtheta } | patrón polar |
Player | tag |
Hitbox(Rect) | pequeño, 2-4 px |
Graze(Timer) | cerca sin tocar |
Bomb | clear de pantalla |
PowerLevel | nivel del arma del jugador |
BossPhase | enum: fase actual |
PhaseTransition(Timer) | cambio de fase |
DialogueBox | texto durante patrón |
ScoreMultiplier | x1, x2, x4, x8… |
Rank | dificultad oculta |
BackgroundLayer | parallax del fondo |
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
bevy_hanabi — para los haces de partículas cuando hay 5.000 balas. GPU, por favor.bevy_gizmos — útil en debug para visualizar PolarCoordinate.bevy_rapier2d — solo para colisión círculo-círculo de balas con jugador.bevy_asset_loader — cargar patrones desde RON.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.
}
}
}
}
┌─────────────────────┐
│ 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.
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.
| Componente | Función |
|---|---|
Transform | posición |
Velocity | movimiento |
Facing(Direction) | izquierda / derecha |
Fighter | tag |
Health | vida |
ComboCounter | contador de golpes encadenados |
Attack { kind, frame, hitbox } | estado de ataque |
Hitstun(Timer) | aturdido, no puede actuar |
Blockstun(Timer) | bloqueando |
BlockHold | sigue bloqueando |
Throw | estado de agarre |
Weapon | tag: tiene objeto cogido |
ThrowInvuln | intocable al ser soltado |
AIKind | goon, mini-boss, boss |
Spawner | genera enemigos por oleada |
Wave(u8) | número de oleada actual |
ArenaBounds | límites laterales |
Pickup | arma en suelo |
Score { current, multiplier } | puntos estilo arcade |
LivesRemaining | continues |
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
bevy_rapier2d — colisiones de hitbox vs hurtbox.leafwing-input-manager — multi-player local. Cada ActionState por jugador.bevy_gilrs — gamepads.bevy_kira_audio — voces y música adaptativa.bevy_egui para el marcador, o sprite puro estilo arcade.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());
}
}
┌──────────────────────┐
│ 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.
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.
| Componente | Función |
|---|---|
Tile { kind, pos } | celda del tablero |
Pushable | tag: el jugador lo empuja |
Sinkable | desaparece al pisarlo |
Win | tag: bloque que activa victoria |
RuleBlock { noun, operator, value } | Baba is You style |
Rules | recurso con todas las reglas activas |
Player | tag |
Move(Timer) | cooldown de movimiento |
UndoStack | historial para deshacer |
Level | recurso: id del nivel |
MovesCounter | cuántos movimientos llevas |
Par | movimientos mínimos |
Hint | pista opcional |
LockedTile | necesita X pasos antes |
PressurePlate | activa algo al pisarlo |
Conveyor | empuja en una dirección |
Pickup { kind, target } | cubo, llave, etc. |
Door | abre con llave / interruptor |
Animator | para tiles animados |
ParticleFX | celebración al ganar |
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
bevy_ecs_tilemap — base para tableros.bevy_tweening — animar el movimiento tile a tile.bevy_kira_audio — feedback de sonido en cada movimiento.bevy_persistent — guardar progreso por nivel.bevy_asset_loader — cargar cientos de niveles desde RON.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>();
}
}
}
┌──────────────────────┐
│ 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.
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.
| Componente | Función |
|---|---|
Tile | celda 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 |
TimeOfDay | recurso global: 6:00 a 24:00 |
DayCounter | día actual |
Season | primavera, verano… |
Weather | soleado, lluvia |
NPC | aldeano con Schedule |
Schedule | IA de NPC por hora |
Friendship(i16) | puntos con un NPC |
Shop | abre a X hora |
Mailbox | cartas que llegan |
Recipe | qué se puede craftear |
Building | estructuras |
Animal | gallina, vaca, oveja |
Product | output diario |
Money | oro del jugador |
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
bevy_ecs_tilemap — mapa agrícola.bevy_rapier2d — opcional, muchas veces innecesario.bevy_persistent — guardar al final del día.bevy_kira_audio — música por estación.bevy_egui — menú de crafteo.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).
┌──────────────────────────┐
│ 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.)
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.
| Componente | Función |
|---|---|
Building { kind, level, hp } | estructura |
BuildQueue | qué se está construyendo |
Worker | aldeano que trabaja |
WorkerTask | enum: Idle, Build, Gather, Repair |
ResourceDeposit { gold, wood, stone } | almacenamiento |
Tower { range, dmg, fire_rate } | defensa |
TowerTarget | qué enemigo está atacando |
Enemy | unidad enemiga |
PathWaypoint | siguiente punto del camino |
Wave(u8) | número de oleada |
WaveSpawner | genera oleada |
MapFog | niebla de guerra |
Visible | revelado en niebla |
CommandPost | radio de construcción |
PopulationCap | límite de unidades |
Economy | resource global: ingresos |
Research | árbol de mejoras |
BuildRadius | desde dónde construir |
Destructible | tag |
AlertState | Calm, UnderAttack |
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
bevy_pathfinding o bevy_flowfield_tiles_plugin — pathfinding masivo.bevy_ecs_tilemap — base del mapa.bevy_rts_camera — cámara con zoom y pan.bevy_kira_audio — música que se intensifica por oleada.bevy_egui para el HUD, o UI nativa.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)
});
}
┌──────────────────────────┐
│ 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.
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.
| Componente | Función |
|---|---|
DialogueLine { speaker, text, voice } | una línea de diálogo |
DialogueTree | grafo de conversación |
Speaker | quién habla |
Portrait { expression, side } | cara del personaje |
Background | imagen del fondo |
Choice { text, leads_to } | opción del jugador |
Inventory | objetos llevados |
Hotspot | zona clicable del fondo |
Cursor | aspecto del ratón |
LookCursor / UseCursor / TalkCursor | verbos de point & click |
Cutscene | secuencia no interactiva |
SaveSlot | partidas guardadas |
Flag | bool: "has_met_X" |
Variable | int: "suspicion_level" |
Journal | registro de pistas |
Thought | pensamiento interior (Disco Elysium) |
SkillCheck | dado + bonus vs dificultad |
Map | imagen del lugar actual |
BGMTrack | música con crossfade |
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
bevy_yarnspinner o bevy_narrative — diálogo ramificado.bevy_kira_audio — voces y música con crossfade.bevy_tweening — fade entre fondos.bevy_persistent — guardado por slot.bevy_asset_loader — cargar fondos y retratos.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();
}
}
┌────────────────────────┐
│ 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.
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.
| Componente | Función |
|---|---|
Transform | posición |
Velocity | Vec2 |
Runner | tag del jugador |
Jump(Timer) | cadencia de salto |
Slide(Timer) | deslizar |
WallSlide | rebota en pared |
Obstacle | tag: choca |
ObstacleKind | enum: bajo, alto, en pared |
Chunk | pedazo de nivel pre-generado |
ChunkPool | pool de chunks |
DistanceRun | recurso: metros avanzados |
Speed | f32 que crece con el tiempo |
CameraShake | trauma |
Coin | pickup |
Powerup | invencibilidad, imán |
HighScore | resource |
Input | un botón: saltar |
Particle | al chocar |
AudioBeat | ritmo de la música |
DeathCount | cuántas veces has muerto |
FixedUpdate
├─ advance_chunks
├─ recycle_chunks
└─ increase_speed
Update
├─ tap_to_jump
├─ check_collisions
└─ spawn_obstacles
PostUpdate
├─ update_distance_counter
├─ update_camera
└─ render_particles
bevy_rapier2d — colisión con obstáculos.bevy_ecs_tilemap — chunks de fondo.bevy_kira_audio — música a tempo creciente.bevy_persistent — guardar high score.bevy_particles o bevy_hanabi — partículas de polvo al correr.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);
}
}
┌────────────────────────┐
│ 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.
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énero | Plugin estrella | Trampa top |
|---|---|---|
| Platformer | bevy_rapier2d | sin coyote time |
| Roguelike | bevy_rand + bevy_persistent | no limpiar salas |
| Twin-stick | bevy_rapier2d + pool | allocar balas por frame |
| Bullet hell | bevy_hanabi + pool | miles de entidades |
| Beat-em-up | leafwing-input | sin input buffer |
| Puzzle | bevy_ecs_tilemap | animación bloquea input |
| Sim / Tycoon | bevy_persistent | guardar a 60 fps |
| RTS-lite | bevy_flowfield_tiles | A* por enemigo |
| VN / Point&Click | bevy_yarnspinner | sin skip rápido |
| Runner | pool de chunks | RNG 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 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.