Los jugadores odian perder una partida por un tecnicismo. La plataforma era clara, el tiempo era el correcto y, de repente, el juego los mataba, no porque cometieran un error, sino porque la pestaña del navegador perdió el foco.
Vi esto de primera mano en Solstice Leap, un juego arcade de Three.js que construí en torno a una única y satisfactoria mecánica: mantener presionado un botón para cargar un salto, y luego soltarlo para lanzarse a través de los huecos. Durante las pruebas de juego, noté un patrón desesperante. Si alguien hacía Alt-Tab para responder a un mensaje o hacía clic en otra pestaña mientras su carga se estaba acumulando, el personaje se lanzaba al vacío en el momento en que la ventana recuperaba el foco, o a veces inmediatamente al perderlo. El juego había interpretado una interrupción rutinaria del sistema operativo como una liberación intencionada del botón. Las partidas terminaban de forma injusta. La confianza en los controles se erosionaba.
La causa raíz: un evento realizando dos tareas
El error era sutil pero directo. En la capa de entrada original, el código vinculaba la lógica de liberación del salto directamente al evento blur de la ventana:
window.addEventListener("blur", releaseCharge);
Esto parece razonable si lo miras con atención. El jugador estaba manteniendo presionada una tecla o un puntero; ahora algo se detuvo. Pero un evento blur no es un evento de entrada. Es una señal de gestión de la ventana. Se dispara cuando la pestaña del navegador pierde el foco del sistema operativo, lo cual puede ocurrir cuando el jugador cambia de pestaña, minimiza la ventana, hace clic en un monitor externo o incluso cuando una notificación del sistema roba el foco. Ninguna de esas acciones significa "quiero lanzar a mi personaje". Significan "estoy interactuando con algo fuera del juego".
Al redirigir el blur hacia releaseCharge, el juego confundía dos conceptos completamente diferentes: una parada intencionada (el jugador suelta el botón) y una interrupción externa (el navegador ya no es la ventana activa). Debido a que releaseCharge calculaba la fuerza del salto basándose en el estado de carga actual y aplicaba la velocidad inmediatamente, cualquier pérdida de foco a mitad de la carga activaba un lanzamiento con cualquier potencia que se hubiera acumulado. El jugador regresaba para encontrarse con su personaje muerto o su progreso arruinado por un movimiento que nunca autorizó.
Realidades del navegador para desarrolladores de Three.js
Three.js te ofrece un potente canvas 3D, pero la entrada de datos sigue fluyendo a través del DOM. Esa división es importante. El navegador no sabe intrínsecamente que mantener presionada la barra espaciadora carga un salto. Solo sabe que se ha presionado una tecla. Cuando el foco abandona el documento, el navegador no sintetiza automáticamente un keyup para cada tecla mantenida. En su lugar, te indica que la ventana ya no está activa. Si la lógica de tu juego asume que la ausencia de foco equivale a la ausencia de entrada, obtendrás acciones fantasma.
Esta distinción es especialmente importante para las mecánicas de carga, que aparecen en todas partes: tensar un arco, acelerar un vehículo, lanzar un hechizo cargado o correr con una preparación de resistencia. Cualquier acción sostenida que acumule un estado a lo largo del tiempo es vulnerable a la misma mala interpretación. Las aplicaciones nativas suelen pausar toda la simulación al perder el foco. Los juegos de navegador pueden hacer lo mismo, pero incluso si decides que el juego siga ejecutándose, debes separar las interrupciones del sistema de los comandos del jugador.
Separar la intención de la interrupción
La solución requirió dividir la ruta de salida del estado de carga en dos carriles distintos. Un carril gestiona la entrada deliberada. El otro gestiona el soporte vital para cuando el mundo real interfiere.
Liberaciones deliberadas —pointerup y keyup— siguen ejecutando el salto. Estas son las señales directas del jugador para avanzar.
Eventos de pérdida de foco —blur, pointercancel y visibilitychange cuando el documento queda oculto— ahora activan una función separada llamada cancelCharge.
cancelCharge no es una liberación modificada. Es un reinicio forzado. Reduce la fuerza de carga acumulada hasta cero, restaura la escala visual del jugador a su estado de reposo predeterminado, pone a cero el medidor de carga en pantalla y devuelve el juego a su modo de apuntado. Lo más importante es que no toca el código de la trayectoria de lanzamiento. No hay cálculo de velocidad, ni impulso físico, ni salto. La carga se evapora de forma segura.
La nueva conexión se ve conceptualmente así:
window.addEventListener("blur", cancelCharge);
Pero el verdadero cambio arquitectónico es el reconocimiento de que la carga es ahora un estado con dos salidas posibles. En una liberación adecuada, la máquina de estados evalúa el porcentaje de carga, calcula la velocidad del salto y transiciona hacia la animación de salto. En una interrupción, la máquina de estados aborta y vuelve al estado de reposo (idle). Mantener esos caminos separados evita efectos secundarios.
You should also listen for pointercancel. The browser dispatches this when it detects a system-level interruption on the pointing device—things like a palm rejection gesture on touchscreens, a system menu invocation, or a pen losing contact under unusual conditions. Pairing blur with pointercancel covers both desktop multitasking and mobile interruptions. Adding visibilitychange catches the scenario where a user switches tabs without necessarily firing blur on the window object itself, which can happen in some browser and OS combinations.
Testing the Boundary Conditions
Fixing input bugs demands testing outside the happy path. No one finds these issues by calmly playing the game in a single tab. To verify the new behavior, I ran two specific scenarios.
First, I started charging a jump and then forced a blur event by switching browser tabs using the keyboard. The game immediately dropped out of charging mode and returned to aiming. No jump fired. No velocity applied. The charge meter cleared itself. Second, I performed a normal charge and released the button intentionally. The jump executed exactly as it had before, with the same arc and force scaling. The game feel remained intact; only the edge case was patched.
Both paths had to remain independent. A fix that prevents accidental jumps but dulls legitimate ones is not a fix—it is a different bug. Preserving the crispness of the original mechanic while hardening it against browser chaos was the goal.
A Pattern for Sustained Input
This problem extends far beyond platformers. Any Three.js game that relies on a continuous press is exposed. Consider a first-person grappling hook where holding the mouse builds tension, or a racing game where a held key charges a boost. If your teardown logic lives only in a button release handler, and you do not account for tab switching, OS notifications, or screen locks, you are allowing the operating system to play your game for you.
The broader pattern is to build your input layer with three explicit states: active input, released input, and cancelled input. Active input builds the charge or initiates the action. Released input commits it. Cancelled input kills it cleanly. Never let a window blur masquerade as a release. The browser is a host, not a player.
Keep Human Behavior in Mind
People switch tabs. They answer direct messages. They look up a guide on their second monitor. They get work Slack pings. These are not edge cases; they are standard behavior inside a browser. A browser game that punishes normal human multitasking feels fragile. By treating focus loss as a cancellation rather than a command, Solstice Leap now lets players step away for a second without sacrificing a carefully set up jump.
A blur event is not a release event. It is simply the browser saying it stepped out of the room. Code accordingly, and your players will trust the controls enough to take the leap when they actually mean to.
