کھلاڑی کسی تکنیکی وجہ سے اپنا رن (run) ہارنا ناپسند کرتے ہیں۔ پلیٹ فارم واضح تھا، ٹائمنگ بالکل درست تھی، اور پھر گیم نے انہیں اس لیے نہیں مارا کہ انہوں نے کوئی غلطی کی تھی، بلکہ اس لیے کہ براؤزر ٹیب کا فوکس (focus) ختم ہو گیا تھا۔

میں نے یہ خود Solstice Leap میں دیکھا، جو کہ ایک Three.js آرکیڈ گیم ہے جسے میں نے ایک ہی پرلطف میکانزم کے گرد بنایا تھا: چھلانگ کے لیے چارج کرنے کے لیے بٹن کو دبا کر رکھیں، اور پھر اسے خلا میں اچھلنے کے لیے چھوڑ دیں۔ پلے ٹیسٹ کے دوران، میں نے ایک پریشان کن پیٹرن نوٹ کیا۔ اگر کوئی میسج کا جواب دینے کے لیے Alt-Tab کرتا یا چارجنگ کے دوران کسی دوسرے ٹیب پر کلک کرتا، تو ونڈو پر واپس کلک کرتے ہی کردار خود بخود خلا میں گر جاتا—یا کبھی کبھی فوکس ختم ہوتے ہی۔ گیم نے آپریٹنگ سسٹم کی ایک معمول کی مداخلت کو بٹن کا جان بوجھ کر چھوڑنا سمجھ لیا تھا۔ رنز (runs) غیر منصفانہ طور پر ختم ہو جاتے۔ کنٹرولز پر سے اعتماد ختم ہونے لگا۔

اصل وجہ: ایک ایونٹ کے دو کام

یہ بگ باریک تھا لیکن براہ راست تھا۔ اصل ان پٹ لیئر (input layer) میں، کوڈ نے جمپ ریلیز (jump release) کے لاجک کو براہ راست ونڈو کے blur ایونٹ کے ساتھ جوڑ دیا تھا:

window.addEventListener("blur", releaseCharge);

اگر آپ غور سے دیکھیں تو یہ مناسب لگتا ہے۔ کھلاڑی نے کوئی کی (key) یا پوائنٹر دبا رکھا تھا؛ اور اب کچھ رک گیا۔ لیکن blur ایونٹ کوئی ان پٹ ایونٹ نہیں ہے۔ یہ ونڈو مینجمنٹ کا ایک سگنل ہے۔ یہ اس وقت فائر ہوتا ہے جب براؤزر ٹیب کا آپریٹنگ سسٹم سے فوکس ختم ہو جاتا ہے، جو اس وقت ہو سکتا ہے جب کھلاڑی ٹیب بدلتا ہے، ونڈو کو منیمائز (minimize) کرتا ہے، کسی بیرونی مانیٹر پر کلک کرتا ہے، یا یہاں تک کہ جب سسٹم نوٹیفکیشن فوکس حاصل کر لیتا ہے۔ ان میں سے کوئی بھی عمل اس کا مطلب نہیں ہے کہ "میں اپنے کردار کو لانچ کرنا چاہتا ہوں۔" ان کا مطلب ہے "میں گیم سے باہر کسی چیز کے ساتھ بات چیت کر رہا ہوں۔"

blur کو releaseCharge میں شامل کرنے سے، گیم نے دو بالکل مختلف تصورات کو یکجا کر دیا: ایک جان بوجھ کر کیا گیا وقفہ (کھلاڑی نے بٹن چھوڑ دیا) اور ایک بیرونی مداخلت (براؤزر اب ایکٹو ونڈو نہیں رہی)۔ چونکہ releaseCharge موجودہ چارج اسٹیٹ کی بنیاد پر جمپ فورس کا حساب لگاتا تھا اور فوری طور پر ویلوسٹی (velocity) لاگو کرتا تھا، اس لیے چارجنگ کے دوران فوکس کے کسی بھی خاتمے نے جمع شدہ طاقت کے ساتھ لانچ ٹریگر کر دیا۔ کھلاڑی واپس آیا تو اسے اپنا کردار مردہ ملا یا اس کی پیش رفت ایک ایسے اقدام سے برباد ہو گئی جس کی اس نے کبھی اجازت نہیں دی تھی۔

Three.js ڈویلپرز کے لیے براؤزر کی حقیقتیں

Three.js آپ کو ایک طاقتور 3D کینوس دیتا ہے، لیکن ان پٹ اب بھی DOM کے ذریعے بہتا ہے۔ یہ فرق اہمیت رکھتا ہے۔ براؤزر کو فطری طور پر یہ معلوم نہیں ہوتا کہ اسپیس بار (spacebar) دبا کر رکھنے سے جمپ چارج ہوتا ہے۔ اسے صرف یہ معلوم ہوتا ہے کہ ایک کی (key) دبائی گئی ہے۔ جب فوکس دستاویز (document) سے نکل جاتا ہے، تو براؤزر خود بخود ہر دبی ہوئی کی کے لیے keyup پیدا نہیں کرتا۔ اس کے بجائے، وہ آپ کو بتاتا ہے کہ ونڈو ختم ہو گئی ہے۔ اگر آپ کے گیم لاجک کا یہ خیال ہے کہ فوکس کی عدم موجودگی کا مطلب ان پٹ کی عدم موجودگی ہے، تو آپ کو خیالی ایکشنز (phantom actions) ملیں گے۔

یہ فرق خاص طور پر چارج اپ میکانزم کے لیے اہم ہے، جو ہر جگہ نظر آتے ہیں: کمان کھینچنا، گاڑی کی رفتار بڑھانا، چارج شدہ جادو کرنا، یا اسٹیمنا کے ساتھ دوڑنا۔ کوئی بھی مسلسل عمل جو وقت کے ساتھ اسٹیٹ (state) جمع کرتا ہے، وہ اسی غلط فہمی کا شکار ہو سکتا ہے۔ نیٹو ایپلی کیشنز اکثر فوکس ختم ہونے پر پوری سمولیشن کو روک دیتی ہیں۔ براؤزر گیمز بھی ایسا کر سکتے ہیں، لیکن اگر آپ گیم کو چلتا ہوا رکھنا چاہتے ہیں، تو آپ کو سسٹم کے انٹروپٹس (interrupts) کو کھلاڑی کے کمانڈز سے الگ کرنا ہوگا۔

ارادے کو مداخلت سے الگ کرنا

اس مسئلے کے حل کے لیے چارجنگ اسٹیٹ سے نکلنے کے راستے کو دو الگ راستوں میں تقسیم کرنا ضروری تھا۔ ایک راستہ جان بوجھ کر کیے گئے ان پٹ کو سنبھالتا ہے۔ دوسرا راستہ اس وقت کے لیے ہے جب حقیقی دنیا مداخلت کرے۔

جان بوجھ کر کی گئی ریلیزpointerup اور keyup—اب بھی جمپ کو نافذ کرتے ہیں۔ یہ کھلاڑی کے براہ راست سگنلز ہیں۔

فوکس کے خاتمے کے ایونٹسblur، pointercancel اور visibilitychange جب دستاویز چھپ جاتی ہے—اب ایک الگ فنکشن کو ٹریگر کرتے ہیں جسے cancelCharge کہا جاتا ہے۔

cancelCharge کوئی تبدیل شدہ ریلیز نہیں ہے۔ یہ ایک ہارڈ ری سیٹ (hard reset) ہے۔ یہ جمع شدہ چارج فورس کو واپس صفر کر دیتا ہے، کھلاڑی کے ویژول اسکیل (visual scale) کو اس کی ڈیفالٹ آئیڈل اسٹیٹ (idle state) پر بحال کرتا ہے، اسکرین پر موجود چارج میٹر کو صفر کر دیتا ہے، اور گیم کو اس کے ایمنگ موڈ (aiming mode) میں واپس لے جاتا ہے۔ سب سے اہم بات یہ ہے کہ یہ لانچ ٹریجیکٹری (launch trajectory) کے کوڈ کو نہیں چھیڑتا۔ اس میں کوئی ویلوسٹی کیلکولیشن، کوئی فزکس امپلس (physics impulse) اور کوئی چھلانگ نہیں ہوتی۔ چارج محفوظ طریقے سے ختم ہو جاتا ہے۔

اپ ڈیٹ شدہ وائرنگ تصوراتی طور پر ایسی نظر آتی ہے:

window.addEventListener("blur", cancelCharge);

لیکن اصل آرکیٹیکچرل تبدیلی یہ پہچان ہے کہ چارجنگ اب ایک ایسی اسٹیٹ ہے جس کے دو ممکنہ اخراج کے راستے ہیں۔ ایک صحیح ریلیز پر، اسٹیٹ مشین چارج فیصد کا جائزہ لیتی ہے، جمپ ویلوسٹی کا حساب لگاتی ہے، اور چھلانگ کے اینیمیشن میں منتقل ہو جاتی ہے۔ مداخلت کی صورت میں، اسٹیٹ مشین عمل کو روک دیتی ہے اور آئیڈل (idle) حالت میں واپس آ جاتی ہے۔ ان راستوں کو الگ رکھنے سے ضمنی اثرات (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.