⏱️ Lectura: 14 min
El Walker Playground de Mark Blah deja caer, arrastrar y lanzar muñecos de palo sobre una página web sin un solo motor de juegos de por medio. Cada piso, pared y plataforma es un elemento HTML real: un botón, un párrafo, el borde de una tarjeta. Redimensionar la ventana rápido puede lanzar un muñeco por el aire, porque el piso bajo sus pies literalmente se movió.
📑 En este artículo
- TL;DR
- ¿Qué es la física 2D sin motor?
- Por qué importa
- Cómo funciona
- Ejemplos prácticos
- Cómo empezar
- Casos de uso reales
- Errores comunes y buenas prácticas
- Comparativa con alternativas
- Profundizando
- Preguntas frecuentes
- ¿Necesito WebGL o canvas para lograr física 2D sin motor?
- ¿Qué hace que una simulación física en el DOM se sienta pesada?
- ¿La física DOM-based funciona igual en cualquier navegador?
- ¿Cuándo conviene cambiar un motor de físicas casero por una librería como Matter.js?
- ¿Por qué el texto de un párrafo no bloquea a un walker en una simulación de colisiones basada en rects?
- Referencias
Esa idea, hacer física 2D sin motor con solo medir el DOM, no necesita canvas, WebGL ni una librería de físicas. Alcanza con getBoundingClientRect(), un bucle con requestAnimationFrame() y algo de álgebra de secundaria.
TL;DR
- getBoundingClientRect() devuelve la posición y el tamaño real de un elemento en pantalla, sin necesitar canvas ni WebGL.
- Un bucle con requestAnimationFrame suma gravedad a una velocidad vertical y mueve el elemento con transform: translate.
- Comparar los bordes de dos rects (bottom contra top) basta para detectar colisiones tipo AABB entre dos elementos.
- Guardar el rect de un frame anterior y restarlo al actual calcula la velocidad que el resize le imprime a un objeto.
- Range.getClientRects() devuelve un rect por cada línea de texto, la base para que un párrafo se comporte como varios pisos.
¿Qué es la física 2D sin motor?
La física 2D sin motor es la técnica de simular gravedad, velocidad y colisiones usando únicamente las medidas reales del DOM, la posición y el tamaño de cada elemento vía getBoundingClientRect(), sin canvas, WebGL ni un motor de juegos externo: el propio documento hace de escenario y de sensor.
No hay física «de mentira» dibujada sobre un lienzo aparte. Si un botón mide 120 por 40 píxeles y está en cierta posición de la pantalla, esos son exactamente los números que el bucle de física usa para decidir si un muñeco choca contra él o lo atraviesa.
Por qué importa
La mayoría de las físicas en la web viven detrás de una caja negra: un canvas pintado a pixel o un motor como Matter.js que abstrae resortes y fricción. El walker playground invierte eso: la simulación corre sobre el propio documento, y cada botón, imagen o línea de texto que ya existe en la página se convierte en un obstáculo real sin dibujarlo de nuevo.
Esto importa por dos razones. Primero, pedagógica: para montar una simulación física en el DOM hay que entender a fondo qué devuelve getBoundingClientRect(), cuándo el navegador recalcula el layout y por qué leer y escribir estilos en el orden equivocado destruye el rendimiento. Segundo, práctica: la misma técnica sirve para notificaciones que caen con gravedad, tarjetas que se lanzan para descartarlas o páginas de error 404 con elementos que reaccionan al resize de la ventana.
No hace falta cargar una librería pesada para lograrlo. El código completo de una física básica (gravedad, piso, arrastre) cabe en menos de 60 líneas de JavaScript y corre en cualquier navegador moderno sin dependencias.
Cómo funciona
Para lograr física 2D sin motor, el bucle principal necesita resolver tres problemas en cada frame: medir dónde está todo, acelerar a cada objeto según la gravedad y corregir la posición cuando dos rects se superponen.
El bucle principal: requestAnimationFrame
Cada frame del navegador dispara una función que actualiza el estado físico y vuelve a pedir el siguiente frame. No hay temporizador fijo: requestAnimationFrame() se sincroniza con la tasa de refresco real de la pantalla, así que en un monitor de 60Hz corre unas 60 veces por segundo.
flowchart TD
A["Medir rects con getBoundingClientRect"] --> B["Aplicar gravedad a la velocidad"]
B --> C["Mover el walker con transform"]
C --> D["Comparar bordes del walker y el piso"]
D --> E{"Hay colision?"}
E -->|"si"| F["Corregir posicion y poner vy en 0"]
E -->|"no"| G["Seguir cayendo"]
F --> A
G --> A
Gravedad y velocidad
La integración más simple (y la que usa casi cualquier demo de física DOM-based) es Euler semi-implícito: en cada frame la velocidad vertical crece una cantidad fija (la gravedad) y luego la posición se mueve según esa velocidad. No es exacta como la física real, pero visualmente es indistinguible y es trivial de programar.
Detectar colisiones comparando rects (AABB)
Dos rectángulos alineados a los ejes (sin rotación) colisionan si se superponen en ambos ejes a la vez. Para un muñeco que solo cae, alcanza con comparar el borde inferior del walker contra el borde superior del piso: si rectWalker.bottom >= rectPiso.top, hay contacto.
Plataformas especiales: texto, formularios y bordes
El walker playground no trata a todos los elementos igual. Los bordes de una tarjeta son sólidos de verdad (hay que caminar por el borde superior o quedar atrapado dentro). Los botones y los campos de formulario también bloquean el paso. El texto, en cambio, es un piso que se puede pisar pero también atravesar, así que un muñeco puede caer de línea en línea dentro de un párrafo largo.
Ejemplos prácticos
Veamos la progresión completa, de un muñeco que cae sin parar a uno que choca contra un piso y reacciona al resize.
Primero, un walker que solo cae, sin piso:
const walker = document.getElementById('walker');
let y = 0;
let vy = 0;
const gravedad = 0.6;
function tick() {
vy += gravedad;
y += vy;
walker.style.transform = `translateY(${y}px)`;
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
Con gravedad = 0.6, la velocidad crece 0.6 por frame: 0.6, 1.2, 1.8, 2.4, 3.0… Sumando la posición frame a frame, tras exactamente 5 frames y vale 9 (0.6 + 1.8 + 3.6 + 6.0 + 9.0 por acumulación, verificable sumando vy en cada paso). Sin piso, el walker sigue acelerando para siempre y sale de la pantalla.
Ahora agreguemos un piso real y comparemos rects para detener la caída:
function detenerEnElPiso() {
const rectWalker = walker.getBoundingClientRect();
const rectPiso = piso.getBoundingClientRect();
if (rectWalker.bottom >= rectPiso.top) {
y -= rectWalker.bottom - rectPiso.top;
vy = 0;
}
}
Si el piso está a 300px desde arriba y el walker mide 40px de alto, la condición se cumple en cuanto y llega a 260: ahí rectWalker.bottom vale exactamente 300, el mismo número que rectPiso.top, y el walker queda clavado en el piso en vez de atravesarlo.
Por último, el truco que hace al walker playground memorable: convertir el resize en un lanzamiento.
let pisoRectAnterior = piso.getBoundingClientRect();
window.addEventListener('resize', () => {
const pisoRectActual = piso.getBoundingClientRect();
const deltaY = pisoRectActual.top - pisoRectAnterior.top;
vy += deltaY;
pisoRectAnterior = pisoRectActual;
});
Si el piso sube 50px entre dos eventos de resize consecutivos (pisoRectActual.top pasa de 300 a 250), deltaY vale -50 y esa cantidad se suma directo a vy: el walker sale disparado hacia arriba con la misma velocidad con la que se movió el piso.
Cómo empezar
Dependencias: git, un navegador moderno y, opcionalmente, python3 (viene preinstalado en macOS y en la mayoría de las distros Linux; en Windows se descarga desde python.org) para servir los archivos por HTTP en lugar de abrirlos con file://, que bloquea algunas APIs del navegador.
git clone https://github.com/markblah/lemmings.git
cd lemmings
python3 -m http.server 8000
Después abrí http://localhost:8000 en el navegador. El comando es idéntico en Windows, macOS y Linux; en Windows, si python3 no se reconoce, probá con python -m http.server 8000.
Casos de uso reales
La técnica no se queda en una curiosidad. Notificaciones que caen con gravedad al aparecer, tarjetas que se pueden «flickear» para descartarlas, portafolios con easter eggs donde el logo cae si lo arrastrás, y páginas de error 404 donde los elementos rotos literalmente se desarman son usos directos de la misma simulación de colisiones basada en rects.
También sirve como ejercicio para entender mejor interacciones de arrastre con inercia, como un tablero kanban donde una tarjeta soltada con velocidad sigue deslizándose unos milisegundos antes de frenar, en vez de detenerse en seco apenas se suelta el mouse.
Errores comunes y buenas prácticas
El primer error, y el más caro: intercalar lecturas y escrituras del DOM. Llamar a getBoundingClientRect() justo después de cambiar un style fuerza un reflow síncrono, y hacerlo por cada objeto en cada frame multiplica el costo.
💡 Tip: leé todos los rects que necesitás en un solo paso al principio del frame, y recién después escribí los estilos nuevos. Si intercalás lectura y escritura, cada getBoundingClientRect() fuerza un reflow síncrono y el framerate se desploma con más de una docena de elementos.
Segundo error: mover objetos cambiando top o left en vez de transform. Cambiar top dispara layout en cada frame; transform: translate() solo le pide al compositor que repinte, sin recalcular el documento entero.
Tercer error: no normalizar por el tiempo real entre frames. Sumar una gravedad fija por frame liga la velocidad de caída al framerate del monitor: un walker cae notablemente más rápido en una pantalla de 120Hz que en una de 60Hz si el código no corrige con el delta de tiempo real entre frames.
Cuarto error: olvidar el scroll. getBoundingClientRect() devuelve coordenadas relativas al viewport, no al documento completo, así que si el contenedor del walker tiene scroll propio hay que sumarle window.scrollY y window.scrollX antes de comparar rects.
sequenceDiagram
participant U as Usuario
participant W as Ventana
participant P as Piso
participant M as Walker
U->>W: arrastra el borde para redimensionar
W->>P: dispara evento resize
P->>M: nueva posicion de getBoundingClientRect
M->>M: calcula vy = top actual menos top anterior
Note over M: un delta grande lanza al walker por el aire
Comparativa con alternativas
La física basada en DOM no es la única opción, y conviene saber cuándo cambiarla por otra cosa.
| Opción | Cuándo usarla | Ventaja | Limitación |
|---|---|---|---|
| DOM + getBoundingClientRect | Prototipos, easter eggs, UI física ligera sobre HTML real | Cero dependencias, interactúa con botones y texto reales | El rendimiento cae con decenas de elementos por el costo del reflow |
| Canvas 2D con bucle propio | Simulaciones con decenas o cientos de objetos | Dibuja a pixel, no dispara reflow del documento | Hay que reimplementar botones y texto a mano dentro del canvas |
| WebGL o WebGPU | Miles de partículas o efectos visuales exigentes | Paralelismo en GPU, el techo de rendimiento más alto | Curva de aprendizaje alta, exagerado para una UI simple |
| Motor de físicas (Matter.js, planck.js) | Simulaciones con resortes, fricción y torque realistas | Colisiones complejas y restricciones ya resueltas y probadas | Suma una dependencia externa y resta control fino sobre el bucle |
Profundizando
El detalle que hace posible que un párrafo se comporte como una escalera de pisos angostos, en vez de un bloque sólido, es Range.getClientRects(): a diferencia de getBoundingClientRect(), que devuelve un único rect que envuelve todo el contenido, este método devuelve un array con un DOMRect por cada línea visual del texto.
const rango = document.createRange();
rango.selectNodeContents(parrafo);
const lineas = rango.getClientRects();
console.log(lineas.length);
Cada elemento de lineas es independiente: uno por cada renglón del párrafo, aunque todas las líneas vivan dentro del mismo elemento <p>. Por eso un párrafo largo puede comportarse como varios pisos angostos en vez de un solo bloque.
📌 Nota: Range.getClientRects() es la única forma estándar de obtener un rect por línea de texto; getBoundingClientRect() sobre el mismo párrafo devuelve un solo rect que envuelve todas las líneas juntas.
El «arrastrar para cargar y flickear para lanzar» del walker playground probablemente usa la misma matemática que el resize: en vez de comparar el rect del piso entre dos frames, compara la posición del puntero entre dos eventos pointermove consecutivos, y al soltar (pointerup) transfiere ese delta a la velocidad del walker.
Sobre rendimiento: medir cada elemento «sólido» (bordes, botones, imágenes, campos) en cada frame y para cada walker es un costo que crece con el producto de ambas cantidades. Una optimización típica es leer todos los rects sólidos una sola vez por frame, guardarlos en una lista, e invalidar esa lista solo cuando ocurre un resize, un scroll o una mutación del layout, en vez de volver a medir por cada walker individualmente.
stateDiagram-v2
[*] --> Cayendo
Cayendo --> EnElPiso: colisiona con una superficie
EnElPiso --> Arrastrado: el mouse lo toma
Arrastrado --> Lanzado: se suelta con velocidad
Lanzado --> Cayendo: la gravedad vuelve a actuar
EnElPiso --> Cayendo: el piso se mueve bajo sus pies
📖 Resumen en Telegram: Ver resumen
Tu próximo paso: cloná el repositorio del walker playground, abrí las devtools y pausá con un breakpoint la llamada a getBoundingClientRect() del piso mientras arrastrás el borde de la ventana, para ver en vivo cómo cambian bottom y top.
Preguntas frecuentes
¿Necesito WebGL o canvas para lograr física 2D sin motor?
No. getBoundingClientRect() y un bucle con requestAnimationFrame() alcanzan para simular gravedad y colisiones directamente sobre elementos HTML, aunque el rendimiento se resiente con muchos objetos a la vez.
¿Qué hace que una simulación física en el DOM se sienta pesada?
Casi siempre, intercalar lecturas y escrituras de estilo: cada getBoundingClientRect() después de modificar un style fuerza un reflow síncrono del navegador.
¿La física DOM-based funciona igual en cualquier navegador?
Sí, porque getBoundingClientRect(), requestAnimationFrame() y Range.getClientRects() son APIs estándar soportadas desde hace años en Chrome, Firefox y Safari.
¿Cuándo conviene cambiar un motor de físicas casero por una librería como Matter.js?
Cuando el proyecto necesita resortes, fricción, torque o restricciones entre varios cuerpos: ahí reimplementar esas fórmulas a mano deja de ser rentable frente a usar una librería ya probada.
¿Por qué el texto de un párrafo no bloquea a un walker en una simulación de colisiones basada en rects?
Porque el walker playground trata cada línea de texto como un piso que se puede atravesar y sobre el que también se puede aterrizar, a diferencia de bordes, botones e imágenes, que sí son sólidos.
Referencias
- Walker Playground: la demo original de Mark Blah que inspira este artículo, con escaleras, plataformas flotantes, tarjetas y texto como piso.
- Repositorio en GitHub: código fuente del walker playground para clonar y correr localmente.
- MDN: Element.getBoundingClientRect(): referencia oficial de la API que mide posición y tamaño de un elemento.
- MDN: Range.getClientRects(): documentación de cómo obtener un rect por cada línea visual de texto.
- MDN: window.requestAnimationFrame(): referencia del método usado para programar el bucle de animación.
📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.
Imagen destacada: Foto de Mohammad Rahmani en Unsplash
¿Te sirvió? ¿Te dio otro error? Contalo abajo: las preguntas se responden y le sirven al siguiente que llegue.
Dejar un comentario
0 Comentarios