Un motor, trece juegos: la capa común del Laboratorio
Una plataforma se gana el nombre cuando la decimotercera cosa sale más barata de construir que la tercera. Este es el motor de juegos de navegador que hay debajo de nuestro Laboratorio: lo que comparten todos los juegos, lo que no comparte ninguno y el puñado de pequeños contratos que impiden que trece juguetes muy distintos se peleen entre sí.
En esta página
En el Laboratorio corren trece juegos: ocho de ellos 3D en tiempo real con WebGL, cinco de ellos originales en Canvas 2D. El recuento no es el número interesante. El número interesante es lo que cuesta el siguiente, y la respuesta a esa pregunta la decide casi por completo qué partes acordamos compartir. Compartimos lo aburrido: cuánto suenan las cosas, cuántos píxeles se le piden a la GPU, qué pasa cuando un móvil rota, qué pasa cuando el navegador tira a la basura el contexto gráfico. No compartimos nada de lo divertido. No hay bucle de juego compartido, ni física compartida, ni sistema de entidades, ni una abstracción de grafo de escena entre un juego y three.js.
- 13
- juegos en el Laboratorio
- 8
- 3D en tiempo real con WebGL
- 120 Hz
- simulaciones de paso fijo
- 1
- tecla de silencio para todos
El Laboratorio a 27 de julio de 2026. Todos los juegos son gratuitos, en el navegador, sin instalación y sin cuenta.
Ocho en WebGL, cinco en canvas
Un único registro describe cada juego: slug, título, lema, tono de acento, qué dispositivos admite, su estado, sus puntos destacados, si es 3D o 2D, y una etiqueta corta en monoespaciado que nombra su maquinaria ("terreno de vóxeles por chunks", ese tipo de cosas). La cuadrícula de /games lee ese registro y se divide en dos: un carrusel de 3D en tiempo real para los juegos de React Three Fiber, y debajo una cuadrícula para los originales, los juegos en Canvas 2D que no necesitan GPU en absoluto.
La regla que hace que la división importe cabe en una línea: cada componente de juego, y cada componente de modo atracción, va envuelto en React.lazy a nivel de módulo. Por tanto, three.js se divide en chunks por juego. Quien abre un original en Canvas 2D y juega una hora nunca descarga un renderizador, y quien abre el índice de juegos no descarga ninguno de los dos: recibe pósteres. Mantener cinco juegos en canvas puro no es nostalgia. Algunas ideas quedan mejor como píxeles, y un juego que no cuesta nada cargar es un juego que la gente termina de verdad.
Niveles de calidad WebGL, adivinados una sola vez
Todos los juegos 3D hacen la misma pregunta al montarse y no la vuelven a hacer nunca: ¿cuánta máquina tengo debajo? La respuesta es un nivel — low, medium, high — calculado a partir de la detección de puntero grueso, navigator.hardwareConcurrency, navigator.deviceMemory y la dimensión menor de la pantalla. Un dispositivo cuenta como móvil cuando el puntero es grueso y el lado corto está por debajo de 700 píxeles CSS. La consecuencia más importante del nivel es un tope duro a la densidad de píxeles del dispositivo: 1,25, 1,5 o 2,0. En un móvil moderno, la diferencia entre un tope de 1,25 y un 3,0 sin limitar es aproximadamente seis veces más fragmentos sombreados, que es la diferencia entre un juego y una estufa.
Ese detalle de Safari merece decirse con claridad en vez de archivarse como bug. Safari no expone deviceMemory, así que un iPhone nunca puede argumentar su ascenso; se clasifica como bajo por defecto. Safari móvil también sostiene menos contextos WebGL vivos que los demás navegadores contra los que publicamos y reclama las pestañas en segundo plano con agresividad, así que la respuesta conservadora es la correcta. Cuando una heurística se equivoca en una dirección que te beneficia, déjala en paz.
Dos decisiones más pequeñas del mismo hook costaron más de resolver que los propios umbrales. El renderizado en servidor devuelve medium — el servidor no puede medir nada, así que adivina el término medio y deja que el cliente corrija al montarse. Y la preferencia de movimiento reducido del usuario no baja el nivel. Es tentador confundirlas, pero son peticiones distintas: deja de mover cosas no es dame menos píxeles, y responder a una preferencia de legibilidad degradando la calidad de imagen sería una forma rara de escuchar. El movimiento reducido se respeta donde corresponde: en la propia animación.
Una tecla de silencio para todo el Laboratorio
No hay ningún archivo de audio en todo el Laboratorio, porque la especificación prohíbe los recursos binarios. Todos los sonidos de todos los juegos salen de un solo módulo de síntesis con una superficie de exactamente dos funciones: beep() y noiseBurst(). Entre las dos cubren un propulsor de acoplamiento, un peón aterrizando, una torreta disparando y un nivel completado, lo que dice menos sobre nuestro diseño de sonido que sobre cuánto del audio de videojuego es modelado de envolvente.
El AudioContext que hay detrás es un singleton perezoso, creado en el primer uso y no al importar, porque un contexto creado antes de que llegue un gesto del usuario nace suspendido y así se queda. Los sonidos que se piden mientras el contexto se reanuda se encolan en vez de descartarse, así que el clic que desbloquea el audio es él mismo audible. Cuando la pestaña se oculta, el contexto se suspende.
Una clave de localStorage gobierna un nodo maestro de ganancia. Silencia un simulador de acoplamiento y el tablero de ajedrez se calla también.
Esa clave — vaynerov:engine3d:muted:v1 — es global a propósito, no por juego. Hoy quince módulos importan el sintetizador, incluida una página que no es un juego en absoluto, y todos cuelgan de una única ganancia maestra. La alternativa, un interruptor de silencio por juego, suena más configurable y es peor: nadie quiere ir a la caza de trece casillas porque ha empezado una reunión. Silenciar es una acción de emergencia, y las acciones de emergencia deben ser totales.
El resto de la capa común es igual de pequeño a propósito:
- Sticks táctiles con una zona muerta del 18% — suficiente para que el temblor del pulgar no dirija, pequeña para que el control fino sobreviva.
- Detección de deslizamiento con un umbral de 24 px, donde un arrastre de ratón cuenta como deslizamiento para que el mismo manejador funcione en un portátil.
- Almacenamiento por juego bajo
vaynerov:<slug>:v1— ajustes y progreso, con espacio de nombres propio para que ningún juego pueda leer el estado de otro. - Movimiento reducido como un hook que fija un valor inicial y luego se suscribe, para que una preferencia cambiada a mitad de sesión surta efecto sin recargar.
- Gestión de pérdida de contexto, montada dentro del canvas, que convierte un contexto WebGL perdido en un póster en vez de en un rectángulo negro.
Esta última lleva un comentario honesto en su propio código: la guarda está conectada al escenario del escaparate, y los siete escenarios por juego restantes todavía la necesitan. Es el asunto abierto más antiguo de la capa. Hoy, un contexto perdido en la página de un juego produce un límite de error en vez de un póster elegante: recuperable, no bonito.
Ningún bucle de juego compartido
La decisión más útil de toda la capa fue negarnos a escribir lo que todo motor escribe primero: un bucle de actualización compartido.
En su lugar, cada juego posee su propio bucle, y los que tienen física de verdad corren con paso fijo — 120 Hz en el simulador de acoplamiento y en el apilador de grúa y péndulo, donde un paso variable cambia visiblemente la dificultad del juego. Y lo que es más importante: la simulación de varios juegos vive en un módulo plano con un comentario arriba que dice sin three.js, sin React: el mensajero estilo crossy road, el RTS de bolsillo, el corredor de carriles y la isla de vóxeles guardan todos sus reglas en archivos que no importan nada. La física que puedes llamar desde un archivo de test es física en la que puedes confiar; la física enredada en un callback de renderizado es un rumor.
El rédito más claro está en Slipstream, el corredor de carriles. Sus filas de obstáculos se generan a partir de patrones, y un patrón bonito puede ser aun así imposible: un hueco por el que podrías pasear se convierte en un muro en cuanto la pista se mueve a 32 unidades por segundo. Así que el archivo de patrones incluye un validador que recorre cada fila y demuestra que sigue habiendo un carril transitable alcanzable a la velocidad del peor caso, propagando la alcanzabilidad hacia delante por los tres carriles. Corre al inicializar el módulo en desarrollo, lo que significa que un patrón injusto no puede sobrevivir ni a una carga de página, no digamos ya a una publicación.
La parte que nos gusta es la reutilización: el mismo modelo de filas alimenta el piloto automático del modo atracción en el escaparate de juegos. La demo que se juega sola en la portada está sorteando exactamente la alcanzabilidad que comprobó el validador. Justo para el validador es lo mismo que justo para el jugador, y la demo es la prueba corriendo en público.
Un solo canvas detrás de todo
El escaparate de juegos va rotando diapositivas de modo atracción: unos segundos de cada juego, jugándose solo. La implementación ingenua monta un canvas por diapositiva, y en un móvil muere de inmediato: los navegadores limitan el número de contextos WebGL vivos, y en un móvil ese techo es lo bastante bajo como para alcanzarlo antes de que el carrusel complete una vuelta. Así que el escaparate corre un canvas compartido detrás de todas las diapositivas 3D, con exactamente una escena montada a la vez.
Intercambiar escenas dentro de un canvas vivo es la parte fea, y la escondemos tras un velo opaco de DOM con una coreografía de cinco constantes.
| Constante | ms | Papel |
|---|---|---|
| VEIL_UP | 240 | Un panel opaco de DOM se alza sobre el canvas |
| SETTLE | 160 | Un compás detrás del velo mientras se cambia la escena |
| CANVAS_FADE | 700 | El propio canvas hace un fundido entre escenas |
| VEIL_DOWN | 480 | El panel se levanta sobre el juego nuevo |
| COOL_DOWN | 400 | Los siguientes cambios de diapositiva se rechazan hasta que expira |
El intercambio de 3D a 3D. El tiempo de espera existe porque, si no, un visitante que toque el carrusel deprisa puede desmontar una escena a medio construir.
Tras el velo, una línea hace un trabajo desproporcionado: la cámara por defecto se restablece a valores de fábrica — 75° de campo de visión, plano lejano 1000 — antes de cada montaje. Los juegos dejan la cámara donde la necesitaba su mundo. El shooter con bloqueo de puntero deja un plano lejano de 160, que es correcto para una arena de pasillos y catastrófico para la isla de vóxeles, cuyo horizonte necesita al menos 260. Sin el restablecimiento, el orden de las diapositivas decide si un mundo se renderiza o termina bruscamente en niebla. El estado mutable compartido acaba castigándote; un restablecimiento de fábrica es la disculpa más barata.
El canvas solo corre en frameloop="always" mientras está de verdad en pantalla y hay una diapositiva 3D viva; el resto del tiempo no renderiza nada. Y cuando el contexto se pierde igualmente, el escenario recurre a un póster de DOM.
Esos pósteres merecen un párrafo. Los trece juegos tienen uno, y todos son DOM y SVG puros: sin WebGL, sin canvas 2D, sin un solo píxel de arte rasterizado. Esa restricción es lo que los hace seguros de renderizar desde componentes de servidor, y le da a todo el Laboratorio una única cadena de respaldo para tres fallos distintos: el renderizado en servidor, un navegador sin WebGL y un chunk perezoso que aún no ha llegado. El mismo dibujo cubre los tres. Los originales en Canvas 2D comparten un único módulo de póster entre ellos, porque el arte de sus tarjetas es una familia.
Contratos pequeños, trece veces
El resto de la plataforma es una lista corta de acuerdos, cada uno de los cuales existe porque algo salió mal alguna vez.
La clase de dispositivo se fija con pestillo. Un juego pregunta una vez, al montarse, si el dispositivo está soportado, y la respuesta queda clavada para toda la sesión. El comentario del hook lo explica mejor de lo que podríamos nosotros: un redimensionado de ventana a mitad de partida no debe cambiar la clase y sustituir un juego en marcha por la pantalla de bloqueo. La detección lee primero el user agent y después el tamaño del viewport, y devuelve null en el primerísimo renderizado para que el servidor y el cliente coincidan antes de que nada se comprometa. Exactamente uno de los trece juegos es solo para escritorio — el shooter con bloqueo de puntero, que necesita un ratón que capturar — y un dispositivo no soportado recibe una pantalla de bloqueo en condiciones en vez de una experiencia rota.
Hay una regla que deliberadamente no hicimos: los móviles no quedan forzados a controles táctiles en toda la plataforma. Cada juego fija el tipo de puntero al montarse y decide por sí mismo qué sticks y qué pistas de control renderiza. Una tableta con un teclado acoplado es algo real, y una regla común a toda la plataforma se habría equivocado con ella en ambas direcciones.
La pantalla completa es un contrato, no una llamada a una API. Los juegos nunca tocan la Fullscreen API por su cuenta; leen un contexto. Donde la API nativa existe, la usamos. En Safari de iPhone, que no la ofrece para elementos arbitrarios, recurrimos a una pseudopantalla completa en CSS: una carcasa clavada al viewport en una capa de apilamiento alta, con el desplazamiento de página bloqueado para que el rebote no arrastre el tablero a media jugada. Ambos caminos informan del mismo booleano por el mismo hook, y un juego que asigna Escape tiene que retornar pronto mientras está en pantalla completa, porque allí esa tecla es del anfitrión.
Las asignaciones de teclado se separan por intención. Las teclas de movimiento se comparan con e.code, la tecla física, para que WASD siga siendo un rombo bajo la mano izquierda en un teclado AZERTY. Las letras mnemotécnicas — aquellas en las que la letra es lo que importa — se comparan con e.key. El sitio se publica en ruso, y quien juegue con una distribución cirílica debería poder pulsar igualmente la tecla marcada con la letra correcta. Quince archivos del Laboratorio siguen ya la convención.
Cada juego tiene su propio límite de error, con clave por slug, para que un fallo en un juego no pueda filtrarse al siguiente que abra un visitante.
Lo que la especificación prohíbe
La especificación maestra del Laboratorio 3D es sobre todo una lista de prohibiciones, que es la mitad útil de cualquier especificación. Ninguna dependencia npm nueva: todo el Laboratorio es three.js 0.184 con React Three Fiber 9.6 y drei 10.7, y nada más. Ningún recurso binario: todas las texturas son procedurales, todos los sonidos salen del sintetizador. Todo bucle, escuchador, AudioContext y bloqueo de puntero debe limpiarse al desmontar. Todo se pausa con visibilitychange y tras dos segundos sin foco. El movimiento reducido se respeta en todas partes.
La cabecera de la propia especificación sigue diciendo nueve juegos. La escribimos en junio, publicamos tres más en un solo día y nunca actualizamos el recuento — que es el destino ordinario de todo documento que no es ejecutable. Las restricciones aguantaron igualmente, porque las imponen la revisión y la compilación, no la frase de arriba.
Aquí está la economía, ya que esa era la afirmación. El decimotercer juego llegó como su propio directorio de unos cincuenta archivos, más ediciones en cuatro cosas compartidas: el registro, el mapa de pósteres, los seis catálogos de idioma y el resumen del sitio legible por máquina. Ninguna dependencia nueva. Ningún cambio en los niveles de calidad, el sintetizador, el contrato de pantalla completa ni el escaparate. Heredó una tecla de silencio, un pestillo de dispositivo, una cadena de pósteres de respaldo y una convención de teclado sin tener que pensar en ninguna de ellas, y gastó cada línea de su propia complejidad en la única parte que nadie había construido antes.
Ese es todo el rédito de una capa compartida: no que haga juegos, sino que haga las partes de un juego que nadie disfrutaría escribiendo dos veces. Las siete guardas de pérdida de contexto por juego siguen en la lista, y ahí seguirán hasta que un navegador le tire el contexto a alguien que iba ganando.