— Probablemente alguien en una postmortem de Celeste.
Si abres cualquier juego 2D con un depurador y pausas la CPU en un momento cualquiera, verás lo mismo: una función que se llama a sí misma, lee el input, actualiza el mundo, dibuja, y vuelve a empezar. Esa función se llama game loop o bucle de juego, y es la unidad mínima de un juego interactivo. Es el equivalente a una pulsación del corazón: si se para, el juego se congela; si va demasiado rápido, todo se vuelve borroso.
Pong (Atari, 1972) tenía un loop tan simple que casi da risa: leer paddle, mover pelota, dibujar, esperar al siguiente barrido de pantalla. Cuarenta años después, Celeste (Maddy Makes Games, 2018) tiene un loop más complejo, pero la estructura sigue siendo la misma: input → simulación → render → sincronizar con el reloj.
Una analogía absurda: un game loop es como cortar el césped. Cada pasada (cada frame, cada fotograma) haces lo mismo: empujar el cortacésped, avanzar un paso, comprobar si llegaste al final de la línea, y dar la vuelta. Si vas demasiado rápido, te tropiezas; si vas demasiado lento, el jardín se muere. El truco está en ajustar la velocidad al terreno (tu hardware, la complejidad del juego, el input del jugador).
//! cap-03 — sección 3.1: game loop minimalista.
mientras el juego esté abierto:
leer input del jugador
actualizar mundo (física, IA, animaciones)
dibujar mundo en pantalla
dormir lo que falte hasta el próximo frame
Pero hay un detalle que no se ve a simple vista: las cuatro fases no tienen por qué ir a la misma velocidad. Y ese matiz es el que diferencia un juego que se siente bien de uno que se siente "raro".
Hay dos grandes familias de loops:
La idea es simple: en cada frame mides cuánto tiempo pasó desde el último (el "delta time") y escalas todos los movimientos por ese factor. Si el frame tardó 16.6 ms (60 FPS, frames por segundo), tu personaje se mueve 1 píxel; si tardó 33 ms (30 FPS), se mueve 2 píxeles. Así, en tiempo real, "se mueve siempre a la misma velocidad".
//! cap-03 — sección 3.2.1: variable timestep.
mientras el juego esté abierto:
dt = tiempo_entre_frames()
personaje.x += personaje.velocidad * dt
dibujar()
Ventaja: se adapta a cualquier hardware.
Desventaja: la simulación es no determinista. Si en un frame la física se saltó un frame, la bala pasa por encima del enemigo en vez de impactarlo. Esto es el clásico "tunneling" (efecto túnel, cuando un objeto en movimiento es tan rápido que atraviesa otro sin que el detector de colisiones lo registre entre dos frames consecutivos).
Glenn Fiedler lo explicó en su famoso post "Fix Your Timestep!" (2004, blog gafferongames.com), y la industria lleva desde entonces alternando entre los dos. La idea: la simulación avanza en pasos discretos de tamaño fijo (típicamente 1/60 s = 16.666… ms), y el renderizado puede ir a la velocidad que le dé la gana.
//! cap-03 — sección 3.2.2: fixed timestep con acumulador.
PASO_FIJO = 1 / 60
acumulador = 0.0
tiempo_anterior = ahora()
mientras el juego esté abierto:
dt = ahora() - tiempo_anterior
tiempo_anterior = ahora()
acumulador += min(dt, 0.25) // evita spirals of death.
mientras acumulador >= PASO_FIJO:
actualizar_mundo(PASO_FIJO) // física, IA, lógica.
acumulador -= PASO_FIJO
interpolar_estado_visual(alpha) // alpha = acumulador / PASO_FIJO
dibujar()
La interpolación visual (la alpha) suaviza la transición entre dos pasos de simulación: el jugador ve una posición "entre" la lógica del último paso y la del siguiente. El resultado es un juego que se siente igual a 30, 60 o 144 FPS, sin tunneling y con replays reproducibles.
Celeste usa fixed timestep clásico: cada tick de física corre a 60 Hz exactos, y el render interpola. Por eso las plataformas se sienten "perfectas" independientemente del monitor. El cap. 17 (Física) y el cap. 22 (Pathfinding) dependerán de que entiendas esta sección.
Frame (concepto): una "foto" del estado del juego renderizada a pantalla.
Game loop (concepto): bucle principal que ejecuta input, simulación, render y sincronización por cada frame.
Delta time / dt (concepto): tiempo en segundos entre el frame anterior y el actual.
Fixed timestep (concepto): simulación que avanza en pasos discretos de tamaño fijo, independiente del render.
Variable timestep (concepto): simulación que avanza proporcional al tiempo real entre frames.
Tunneling / efecto túnel (concepto): fallo en colisiones cuando un objeto se mueve tan rápido entre dos frames que "atraviesa" al otro.
Spiral of death (concepto): bucle vicioso cuando un frame tarda tanto que el siguiente tiene que "recuperar" aún más simulación, generando frames cada vez más largos.
Interpolación visual (técnica): dibujar un estado intermedio entre dos pasos de simulación para suavizar el movimiento.
Una de las ideas más contraintuitivas de ECS es que una entidad no es un objeto, ni una clase, ni un struct. Es un número: un identificador único que el motor usa para agrupar datos.
//! cap-03 — sección 3.3: entidad como número.
entidad = 42 // "soy el id 42".
entidad.posicion = (10, 20)
entidad.velocidad = (0, 0)
entidad.sprite = "zombi.png"
Lo importante no es el 42, sino los datos asociados. Una entidad con Posición y Velocidad es "algo que se mueve". Una con Posición, Sprite y Invencible es "el jugador con estrella". Una con Vida, Daño y IA es un enemigo. Los datos son los que dan identidad; el id es solo un handle (un identificador opaco que apunta a los datos).
Esta separación tiene tres consecuencias profundas:
Zombi necesita ahora ser venenoso, le añades el componente Veneno { dps: 5 } y ya. No hay jerarquía que reescribir.Vida, recorres la columna de Vida; los CPUs modernos agradecen acceder a memoria contigua.Entity es un struct con un índice y una generation (un contador que distingue versiones distintas del mismo slot reusado); en otros motores es un u64 o un UUID. El formato da igual; lo que cuenta es la idea.Piensa en una entidad como un perchero en un guardarropa: el perchero tiene un número (42), y la ropa que cuelga de él cambia según lo que el personaje se ponga hoy. El perchero no es el personaje; la ropa, sí.
Si la entidad es el dato, el sistema es la función que actúa sobre el dato. La regla clásica: un sistema no posee estado; recibe lo que necesita por argumentos, lo modifica, y devuelve. Bevy lo lleva al extremo: un sistema es una función con una "firma" (los tipos de sus parámetros) que el scheduler analiza para decidir cuándo ejecutarla.
//! cap-03 — sección 3.4: sistemas sin estado propio.
función gravedad(entidades_con_posición_y_velocidad, dt):
para cada entidad:
entidad.velocidad.y += -9.8 * dt
entidad.posicion.y += entidad.velocidad.y * dt
función mover(entidades_con_posición_y_velocidad, dt):
para cada entidad:
entidad.posicion += entidad.velocidad * dt
función colisionar(entidades_con_aabb, mapa):
para cada par:
si overlap(aabb_A, aabb_B):
resolver_choque(A, B)
Cada función pide permisos explícitos: "necesito leer posiciones y velocidades", "necesito escribir velocidades", "necesito acceso al mapa". El motor (o el compilador, en el caso de Bevy) garantiza que esos permisos no se contradigan: si dos sistemas piden escritura exclusiva sobre el mismo componente y se ejecutan en paralelo, hay un conflicto.
Esta es la gran diferencia con OOP, donde enemy.update() puede tocar cualquier cosa que enemy tenga. Aquí, cada sistema es como un inspector de hacienda: solo puede mirar los libros que se le han autorizado.
El renderizado en 2D moderno se parece a un pipeline (cadena de etapas) de fábrica: cada paso transforma la escena y se la pasa al siguiente. Aunque Bevy tiene su propio render graph declarativo, el esqueleto conceptual cabe en 60 líneas de pseudocódigo:
//! cap-03 — sección 3.5: pipeline de render 2D en pseudocódigo.
estructura Escena:
capas: Vector<Capa>
estructura Capa:
nombre: cadena
entidades: Vector<Entidad>
z: entero // orden de pintado; mayor = encima.
función renderizar(escena, viewport, cámara):
limpiar_framebuffer(color_fondo)
// 1) Culling: descartar lo que no se ve.
visibles = []
para cada capa en escena.capas:
para cada entidad en capa.entidades:
si aabb_en_viewport(entidad.aabb, viewport):
visibles.agregar(entidad)
// 2) Ordenar por Z y luego por Y para pseudo-3D (eje Y hacia el fondo).
visibles.ordenar_por(z, luego y)
// 3) Construir batches: agrupar sprites con el mismo atlas/textura.
lotes = agrupar_por_atlas(visibles)
// 4) Emitir draw calls: cada lote es un paquete que la GPU procesa de una.
para cada lote en lotes:
aplicar_textura(lote.atlas)
para cada entidad en lote.entidades:
aplicar_transform(entidad)
dibujar_sprite(entidad.sprite)
// 5) Efectos posteriores (post-process): luz 2D, viñeta, bloom.
aplicar_post_proceso()
presentar_a_pantalla()
Los pasos 1 a 4 son la forward pipeline clásica: barres, descartas lo invisible, ordenas, batches (agrupas), pintas. El paso 5 son los efectos que se aplican después del frame entero: iluminación 2D, viñeta, distorsión, etc. Bevy te permite definir nodos de post-proceso como sistemas dentro del render graph, lo cual es elegante: cada efecto es una caja negra con una entrada y una salida.
Una pregunta que confunde a medio mundo: ¿esto va en un componente o en un recurso (resource, dato global compartido entre sistemas)?
//! cap-03 — sección 3.6: estado vs recurso.
componente Posición { x, y } // por entidad.
componente Velocidad { x, y } // por entidad.
componente Vida { hp, max } // por entidad.
recurso Tiempo { elapsed, delta } // del juego entero.
recurso Mapa { tiles, ancho, alto } // del juego entero.
recurso Marcador { puntos } // del juego entero.
La regla de oro: si lo puedes imaginar pegado a un actor, es componente. Si lo puedes imaginar flotando fuera de la pantalla, es recurso. Bevy en 0.19 da un paso más: cualquier recurso puede ser componente y viceversa. Pero incluso con la puerta doble, saber cuál es cuál ayuda a pensar la arquitectura.
Si un frame tarda demasiado (por ejemplo, 200 ms en vez de 16), tu acumulador intenta "recuperar" 12 pasos de simulación. Pero cada paso extra cuesta CPU, así que el siguiente frame tarda aún más. Llegas a un bucle vicioso donde el juego entero se congela. La defensa estándar es cortar la acumulación: acumulador = min(acumulador, 0.25) (un cuarto de segundo como tope). Si el jugador ve un stutter (pequeño tirón visual), peor que congelar el juego entero.
❌ Acumulador sin tope: si un frame tarda 500 ms, el siguiente intenta simular 30 pasos seguidos y se arrastra más todavía.
✅ `acumulador = min(acumulador, 0.25)`: como mucho un cuarto de segundo de "deuda". El juego da un tirón pero no muere.
💡 Por qué: cuando el hardware patina, la simulación debe saber contenerse. Bevy aplica el mismo principio en su `Time<Virtual>::advance_to`.
El tick rate es la frecuencia a la que tu simulación avanza. Hay juegos donde la simulación va a 60 Hz, otros a 30, otros a 20. Cuanto más alto, más suave, pero más CPU. Los juegos de lógica por turnos (XCOM, Into the Breach) van a la velocidad del jugador; los shooters van al ritmo del tick de física.
Bevy separa esto en schedules: Update corre cada frame de render; FixedUpdate corre a paso fijo (por defecto 60 Hz); PostUpdate corre al final del frame; PreUpdate corre al principio. Cada sistema se registra en uno de estos schedules (conjuntos de sistemas agrupados por el momento en que deben ejecutarse), y el scheduler los ejecuta en orden.
La consecuencia práctica: tu input va en Update, tu física va en FixedUpdate, tu render va en PostUpdate, tu UI va en Update o PostUpdate. Una IA costosa puede ir en un schedule de menor prioridad que el render. Lo veremos en detalle en el cap. 7.
Game loop (concepto): bucle principal de un juego: input → simulación → render → sincronización.
Delta time (concepto): tiempo en segundos desde el frame anterior. Base del timestep variable.
Fixed timestep (concepto): simulación a pasos discretos de tamaño fijo.
Variable timestep (concepto): simulación proporcional al tiempo real.
Interpolación visual (técnica): dibujar entre dos estados lógicos para suavizar.
Tick (concepto): un paso discreto de simulación. 60 Hz = 60 ticks por segundo.
Tick rate (concepto): número de ticks por segundo de la simulación.
Frame (concepto): imagen renderizada a pantalla. Suele coincidir con un tick, pero no siempre.
Framebuffer (gráficos): memoria de vídeo donde se pinta el frame antes de enviarlo al monitor.
Batching (técnica): agrupar sprites con la misma textura en un solo *draw call* (orden de dibujo enviada a la GPU) para mejorar rendimiento.
Culling (técnica): descartar lo que no se ve para no gastar GPU en balines fuera de cámara.
Z-order (concepto): orden de pintado en pantalla. Mayor Z = más al frente.
Post-process (concepto): efectos aplicados al frame entero tras el render (bloom, viñeta, etc.).
AABB / Axis-Aligned Bounding Box (concepto): caja rectangular alineada con los ejes, usada como colisión simple.
Render graph (concepto): grafo de nodos de render conectados, declarativo en motores modernos.
Schedule (concepto): agrupación de sistemas por etapa (Update, FixedUpdate, etc.).
Prince of Persia (Jordan Mechner, 1989) — uno de los primeros juegos 2D con animación *frame-by-frame* sincronizada con un timer interno. La sensación de "tiempo real" dependía de mantener ese timer estable, aunque el hardware fuese lentísimo.
Celeste (Maddy Makes Games, 2018) — el *coyote time* (periodo breve en el que el jugador puede saltar después de salir de una plataforma) y el *fixed timestep* son las dos decisiones técnicas que hicieron al juego sentirse "justo". El cap. 17 las aprovecha.
Doom (id Software, 1993) — el *game loop* corría a 35 FPS fijos en hardware DOS. La música cambiaba con el estado del jugador, anticipando la música adaptativa.
Space Invaders (Taito, 1978) — su bucle era tan ajustado que, para hacer el juego más difícil conforme el jugador avanzaba, Tomohiro Nishikado simplemente aceleraba el *frame rate* (al haber menos enemigos que mover, el procesador terminaba antes y el bucle daba más pasadas por segundo).
Stardew Valley (ConcernedApe, 2016) — corre a 60 FPS fijos en PC; en Switch baja dinámicamente. Su *tick* agrícola (cada planta crece cada N minutos de juego) está desacoplado del FPS.
Problema: la lógica de tu juego (física, IA, input) quiere ir a paso fijo; el render quiere ir lo más rápido posible. Si los mezclas, tienes tunneling en hardware lento y pausas extrañas en hardware rápido.
Solución: separador en dos dominios. La lógica corre en pasos discretos (típicamente 60 Hz) dentro de un FixedUpdate. El render corre cada frame y lee el último estado lógico conocido, interpolando entre dos snapshots (instantáneas de los datos en un instante dado) si quiere suavidad extra.
//! cap-03 — sección 3.10: límite lógico/render.
estado_anterior = capturar_mundo() // snapshot 1.
estado_actual = estado_anterior
cada frame:
input.leer()
// avanzar lógica en pasos discretos hasta consumir el tiempo real.
acumulador += dt_real
mientras acumulador >= PASO_FIJO:
estado_anterior = estado_actual
estado_actual = simular(estado_actual, PASO_FIJO)
acumulador -= PASO_FIJO
// render con interpolación visual.
alpha = acumulador / PASO_FIJO
dibujar(estado_anterior, estado_actual, alpha)
Cuándo sí:
Cuándo no:
En el cap. 7 de este libro pondremos esto en práctica con Schedule y FixedUpdate de Bevy. Pero la idea, espero, ya está en tu cabeza: la lógica manda, el render pinta.
Entramos en materia. Capítulo 4: primer contacto con Bevy. Creamos una App, registramos un Component, registramos un System, y en 40 líneas tenemos un mini Pong en pantalla. Después, en el cap. 5, le añadimos un marcador y un teclado de verdad.
Parte I entregada: Cap 1 (Historia, ~2.931 palabras), Cap 2 (Rust 30 min, ~2.545 palabras) y Cap 3 (Anatomía 2D, ~3.017 palabras). Cajas glosario, trivia y metedura-de-pata presentes en los tres caps; analogías absurdas intercaladas; citas históricas con año+estudio verificadas vía web (Space Invaders 1978 Taito, Pac-Man 1980 Namco/Funaki/Iwatani, Super Mario Bros 1985 Nintendo, Prince of Persia 1989 Mechner, Doom 1993 id Software, Warcraft 1995 Blizzard, Dungeon Keeper 1997 Bullfrog, Baldur's Gate 1998 BioWare, Thief 1998 Looking Glass, RollerCoaster Tycoon 1999 Sawyer, Overwatch GDC 2017 Tim Ford talk, Celeste 2018 Maddy Makes Games, Hades 2020 Supergiant, Amethyst→Bevy 2021). Snippet de Rust compilable (sin Bevy) en Cap 2. Pseudocódigo NO Bevy en Cap 3. Cada cap cierra con "Lo que vimos" + "En el siguiente". Cap 3 termina con esta sección VERDICT.
VERDICT: PASS