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.

Anda juga harus mendengarkan pointercancel. Browser mengirimkan ini saat mendeteksi interupsi tingkat sistem pada perangkat penunjuk—seperti gestur palm rejection pada layar sentuh, pemanggilan menu sistem, atau pena yang kehilangan kontak dalam kondisi yang tidak biasa. Memasangkan blur dengan pointercancel mencakup multitasking desktop maupun interupsi seluler. Menambahkan visibilitychange menangkap skenario di mana pengguna berpindah tab tanpa harus memicu blur pada objek window itu sendiri, yang dapat terjadi pada beberapa kombinasi browser dan OS.

Menguji Kondisi Batas

Memperbaiki bug input menuntut pengujian di luar happy path. Tidak ada yang menemukan masalah ini hanya dengan memainkan game dengan tenang dalam satu tab. Untuk memverifikasi perilaku baru tersebut, saya menjalankan dua skenario spesifik.

Pertama, saya mulai mengisi daya lompatan dan kemudian memaksa terjadinya event blur dengan berpindah tab browser menggunakan keyboard. Game tersebut segera keluar dari mode pengisian daya dan kembali ke mode membidik. Tidak ada lompatan yang terjadi. Tidak ada kecepatan yang diterapkan. Meteran pengisian menghapus dirinya sendiri. Kedua, saya melakukan pengisian daya normal dan melepaskan tombol dengan sengaja. Lompatan dieksekusi persis seperti sebelumnya, dengan lengkungan dan penskalaan kekuatan yang sama. Game feel tetap terjaga; hanya kasus ekstremnya saja yang telah diperbaiki.

Kedua jalur tersebut harus tetap independen. Perbaikan yang mencegah lompatan tidak sengaja tetapi membuat lompatan yang sah menjadi kurang responsif bukanlah sebuah perbaikan—itu adalah bug yang berbeda. Menjaga ketajaman mekanik asli sambil memperkuatnya terhadap kekacauan browser adalah tujuannya.

Pola untuk Input Berkelanjutan

Masalah ini meluas jauh melampaui game platformer. Setiap game Three.js yang mengandalkan penekanan terus-menerus akan terpapar masalah ini. Pertimbangkan grappling hook orang pertama di mana menahan mouse membangun ketegangan, atau game balap di mana tombol yang ditahan mengisi daya boost. Jika logika teardown Anda hanya berada di dalam handler pelepasan tombol, dan Anda tidak memperhitungkan perpindahan tab, notifikasi OS, atau penguncian layar, Anda membiarkan sistem operasi memainkan game Anda untuk Anda.

Pola yang lebih luas adalah membangun lapisan input Anda dengan tiga status eksplisit: input aktif, input dilepas, dan input dibatalkan. Input aktif membangun daya atau memulai tindakan. Input dilepas mengeksekusinya. Input dibatalkan menghentikannya dengan bersih. Jangan pernah biarkan window blur menyamar sebagai pelepasan. Browser adalah tuan rumah, bukan pemain.

Pertimbangkan Perilaku Manusia

Orang-orang berpindah tab. Mereka membalas pesan langsung. Mereka mencari panduan di monitor kedua mereka. Mereka menerima notifikasi Slack dari pekerjaan. Ini bukan kasus ekstrem; ini adalah perilaku standar di dalam browser. Game browser yang menghukum multitasking manusia normal akan terasa rapuh. Dengan memperlakukan kehilangan fokus sebagai pembatalan alih-alih sebuah perintah, Solstice Leap kini memungkinkan pemain untuk menjauh sejenak tanpa mengorbankan lompatan yang telah diatur dengan cermat.

Sebuah event blur bukanlah event pelepasan. Itu hanyalah cara browser mengatakan bahwa ia sedang keluar dari ruangan. Buatlah kode yang sesuai, dan pemain Anda akan mempercayai kontrolnya cukup untuk melakukan lompatan saat mereka benar-benar menginginkannya.