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 releases—pointerup and keyup—still execute the jump. These are the player’s direct signals to go.
Focus loss events—blur, 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.
Je moet ook luisteren naar pointercancel. De browser stuurt dit event wanneer deze een onderbreking op systeemniveau detecteert op het aanwijsapparaat—denk aan een palm rejection-gebaar op touchscreens, het oproepen van een systeemmenu, of een pen die contact verliest onder ongebruikelijke omstandigheden. Het combineren van blur met pointercancel dekt zowel multitasking op desktop als onderbrekingen op mobiel. Het toevoegen van visibilitychange vangt het scenario op waarbij een gebruiker van tabblad wisselt zonder noodzakelijkerwijs blur op het window-object zelf te triggeren, wat kan gebeuren in bepaalde browser- en OS-combinaties.
Het testen van de randvoorwaarden
Het oplossen van input-bugs vereist testen buiten het happy path. Niemand vindt deze problemen door rustig het spel in één enkel tabblad te spelen. Om het nieuwe gedrag te verifiëren, heb ik twee specifieke scenario's uitgevoerd.
Ten eerste begon ik een sprong op te laden en dwong ik vervolgens een blur-event af door met het toetsenbord van tabblad te wisselen. Het spel schakelde onmiddellijk uit de laadmodus en keerde terug naar het richten. Er werd geen sprong uitgevoerd. Er werd geen snelheid toegepast. De laadmeter werd gewist. Ten tweede voerde ik een normale lading uit en liet ik de knop opzettelijk los. De sprong werd precies zo uitgevoerd als voorheen, met dezelfde boog en krachtschaling. Het spelgevoel bleef intact; alleen de edge case was gepatcht.
Beide paden moesten onafhankelijk blijven. Een fix die per ongeluk springen voorkomt maar legitieme sprongen verzwakt, is geen fix—het is een andere bug. Het doel was om de scherpte van de oorspronkelijke mechaniek te behouden en deze tegelijkertijd te verharden tegen browserchaos.
Een patroon voor aanhoudende input
Dit probleem reikt veel verder dan platformers. Elke Three.js-game die afhankelijk is van een aanhoudende druk is kwetsbaar. Denk aan een first-person grappling hook waarbij het ingedrukt houden van de muis spanning opbouwt, of een racespel waarbij een ingedrukte toets een boost oplaadt. Als je teardown-logica alleen in een button release handler staat en je geen rekening houdt met het wisselen van tabbladen, OS-meldingen of schermvergrendelingen, laat je het besturingssysteem jouw spel voor je spelen.
Het bredere patroon is om je inputlaag te bouwen met drie expliciete statussen: actieve input, vrijgegeven input en geannuleerde input. Actieve input bouwt de lading op of start de actie. Vrijgegeven input bevestigt deze. Geannuleerde input stopt deze netjes. Laat een window blur nooit doen alsof het een release is. De browser is een host, geen speler.
Houd rekening met menselijk gedrag
Mensen wisselen van tabblad. Ze beantwoorden directe berichten. Ze zoeken een handleiding op hun tweede monitor. Ze krijgen Slack-meldingen van werk. Dit zijn geen edge cases; dit is standaardgedrag binnen een browser. Een browsergame die normaal menselijk multitasken straft, voelt fragiel aan. Door focusverlies te behandelen als een annulering in plaats van een commando, laat Solstice Leap spelers nu toe om een seconde weg te kijken zonder een zorgvuldig opgebouwde sprong op te offeren.
Een blur-event is geen release-event. Het is simpelweg de browser die zegt dat hij de kamer heeft verlaten. Codeer dienovereenkomstig, en je spelers zullen de besturing genoeg vertrouwen om de sprong te wagen wanneer ze dat daadwerkelijk willen.
