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.

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.