Players hate losing a run to a technicality. The platform was clear, the timing was right, and then the game killed them not because they made a mistake, but because the browser tab lost focus.

I saw this firsthand in Solstice Leap, a Three.js arcade game I built around a single satisfying mechanic: hold a button to charge a jump, then release it to launch across gaps. During playtests, I noticed a maddening pattern. If someone Alt-Tabbed to reply to a message or clicked another tab while their charge was winding up, the character would hurl itself into the void the moment the window clicked back—or sometimes immediately upon focus loss. The game had interpreted a routine operating system interruption as an intentional button release. Runs ended unfairly. Trust in the controls eroded.

The Root Cause: One Event Doing Two Jobs

The bug was subtle but direct. In the original input layer, the code attached the jump release logic straight to the window’s blur event:

window.addEventListener("blur", releaseCharge);

This looks reasonable if you squint. The player was holding a key or pointer; now something stopped. But a blur event is not an input event. It is a window management signal. It fires when the browser tab loses operating system focus, which can happen when the player switches tabs, minimizes the window, clicks an external monitor, or even when a system notification steals focus. None of those actions mean “I want to launch my character.” They mean “I am interacting with something outside the game.”

By routing blur into releaseCharge, the game conflated two completely different concepts: an intentional stop (the player lets go of the button) and an external interruption (the browser is no longer the active window). Because releaseCharge calculated jump force based on current charge state and immediately applied velocity, any focus loss mid-charge triggered a launch with whatever power had accumulated. The player returned to find their character dead or their progress ruined by a move they never authorized.

Browser Realities for Three.js Developers

Three.js gives you a powerful 3D canvas, but input still flows through the DOM. That split matters. The browser does not inherently know that holding the spacebar charges a jump. It only knows that a key is pressed. When focus leaves the document, the browser does not automatically synthesize a keyup for every held key. Instead, it tells you the window is gone. If your game logic assumes that the absence of focus equals the absence of input, you get phantom actions.

This distinction is especially important for charge-up mechanics, which appear everywhere: drawing a bow, revving a vehicle, casting a charged spell, or sprinting with a stamina wind-up. Any sustained action that accumulates state over time is vulnerable to the same misinterpretation. Native applications often pause the entire simulation on focus loss. Browser games can do the same, but even if you keep running, you must separate system interrupts from player commands.

Splitting Intention from Interruption

The fix required splitting the exit path from the charging state into two distinct lanes. One lane handles deliberate input. The other handles life support for when the real world intrudes.

Deliberate releasespointerup and keyup—still execute the jump. These are the player’s direct signals to go.

Focus loss eventsblur, pointercancel, and visibilitychange when the document becomes hidden—now trigger a separate function called cancelCharge.

cancelCharge is not a modified release. It is a hard reset. It drains the accumulated charge force back to zero, restores the player’s visual scale to its default idle state, zeroes out the on-screen charge meter, and returns the game to its aiming mode. Most importantly, it does not touch the launch trajectory code. There is no velocity calculation, no physics impulse, and no leap. The charge evaporates safely.

The updated wiring looks conceptually like this:

window.addEventListener("blur", cancelCharge);

But the real architectural change is the recognition that charging is now a state with two possible exits. On a proper release, the state machine evaluates charge percentage, computes jump velocity, and transitions into the leap animation. On an interrupt, the state machine aborts and reverts to idle. Keeping those paths separate prevents side effects.

Dovresti anche ascoltare l'evento pointercancel. Il browser lo invia quando rileva un'interruzione a livello di sistema sul dispositivo di puntamento—come un gesto di palm rejection sugli schermi touch, l'invocazione di un menu di sistema o una penna che perde il contatto in condizioni insolite. Abbinare blur a pointercancel permette di gestire sia il multitasking su desktop che le interruzioni su dispositivi mobili. L'aggiunta di visibilitychange intercetta lo scenario in cui un utente cambia scheda senza necessariamente attivare blur sull'oggetto window stesso, cosa che può accadere in alcune combinazioni di browser e OS.

Testare i casi limite

Risolvere i bug di input richiede test al di fuori del percorso standard. Nessuno trova questi problemi giocando con calma al gioco in una singola scheda. Per verificare il nuovo comportamento, ho eseguito due scenari specifici.

Per prima cosa, ho iniziato a caricare un salto e poi ho forzato un evento blur cambiando scheda del browser tramite tastiera. Il gioco è uscito immediatamente dalla modalità di caricamento e si è tornato alla mira. Nessun salto è stato eseguito. Nessuna velocità applicata. Il contatore di carica si è azzerato. In secondo luogo, ho eseguito un caricamento normale e ho rilasciato il pulsante intenzionalmente. Il salto è stato eseguito esattamente come prima, con la stessa parabola e la stessa scala di forza. Il game feel è rimasto intatto; è stato corretto solo il caso limite.

Entrambi i percorsi dovevano rimanere indipendenti. Una correzione che impedisce i salti accidentali ma rende meno reattivi quelli legittimi non è una correzione: è un bug diverso. L'obiettivo era preservare la precisione della meccanica originale, rendendola al contempo più resistente al caos del browser.

Un pattern per l'input prolungato

Questo problema va ben oltre i platform. Qualsiasi gioco Three.js che si affida a una pressione continua è esposto. Considera un rampino in prima persona in cui tenere premuto il mouse accumula tensione, o un gioco di corse in cui un tasto premuto carica un boost. Se la tua logica di teardown risiede solo in un gestore del rilascio del pulsante e non tieni conto del cambio di scheda, delle notifiche del sistema operativo o del blocco dello schermo, stai permettendo al sistema operativo di giocare al tuo posto.

Il pattern più ampio consiste nel costruire il proprio livello di input con tre stati espliciti: input attivo, input rilasciato e input annullato. L'input attivo accumula la carica o avvia l'azione. L'input rilasciato la conferma. L'input annullato la interrompe in modo pulito. Non lasciare mai che un blur della finestra si travesta da un rilascio. Il browser è un ospite, non un giocatore.

Tieni a mente il comportamento umano

Le persone cambiano scheda. Rispondono a messaggi diretti. Cercano una guida sul secondo monitor. Ricevono notifiche di Slack dal lavoro. Questi non sono casi limite; sono comportamenti standard all'interno di un browser. Un gioco per browser che punisce il normale multitasking umano sembra fragile. Trattando la perdita di focus come un'annullazione piuttosto che come un comando, Solstice Leap ora permette ai giocatori di allontanarsi per un secondo senza sacrificare un salto preparato con cura.

Un evento blur non è un evento di rilascio. È semplicemente il browser che dice di essere uscito dalla stanza. Scrivi il codice di conseguenza, e i tuoi giocatori si fideranno dei controlli abbastanza da fare il salto quando lo intendono davvero.