گیم کو ڈیٹرمنسٹک بنانا، دو کوششوں میں
دسمبر 2025 میں CandyRush ایک ایسی Unity گیم نہیں رہی جو اتفاقاً دو بار ایک جیسی
دکھائی دیتی ہو، بلکہ ایک ایسی سمولیشن بن گئی جو ایک ہی seed اور inputs ملنے پر
ہر بار ایک ہی طرح چلتی ہے۔ اس میں ایک ہفتے کے فاصلے سے دو کوششیں لگیں۔ نیچے دیا
گیا ہر عدد GitHub API یا git سے ہے۔
| پہلی کوشش: #155 | دوسری کوشش: #160 | |
|---|---|---|
| عنوان | Feature/strict determinism | compound seeds, strict determinism, performance tests |
| کھلی، مرج ہوئی (UTC) | 17 دسمبر 20:00، 18 دسمبر 14:47 | 25 دسمبر 09:58، 25 دسمبر 10:16 |
| حجم | +28,393/-2,192، 273 فائلیں | +50,455/-6,492، 714 فائلیں |
| Commits | 86 | 117 |
| شامل کی گئی سطروں میں ٹیسٹ attributes | 485 [Test] | 651 [Test]، 168 [UnityTest]، 111 [TestCase] |
| مرج کے وقت CI | EditMode ٹیسٹ ناکام، exit code 2 | EditMode اور PlayMode ٹیسٹ ناکام، exit code 2 |
| نتیجہ | تقریباً 14 گھنٹے بعد revert | برقرار؛ اس کے بعد ہر چیز کی بنیاد |
ٹیسٹ کی تعداد ہر squash diff میں ٹیسٹ attribute سے شروع ہونے والی سطروں کی ہے، اس لیے اس میں منتقل یا دوبارہ لکھے گئے ٹیسٹ بھی شامل ہیں۔ انہیں تخمینہ سمجھیں۔
"سخت ڈیٹرمنزم" کا مطلب کیا تھا
گیم عام Unity طریقے سے لکھی گئی تھی۔ randomness UnityEngine.Random سے آتی تھی۔
حرکت Time.deltaTime سے scale ہوتی تھی۔ تاخیر UniTask.Delay سے ہوتی تھی۔ ٹکراؤ
کی handling OnTriggerEnter2D کے اندر چلتی تھی۔ dictionaries اسی ترتیب میں
iterate ہوتی تھیں جس میں وہ اتفاقاً موجود ہوں۔ ان میں سے ہر ایک انحراف کا ذریعہ
ہے: ایک ہی seed والی دو رنز آہستہ آہستہ الگ ہو جاتی ہیں، اور ریکارڈ کی گئی رن
دوبارہ نہیں چلائی جا سکتی۔
کام پہلے 60 نمبر شدہ tasks کے ایک backlog کی صورت میں لکھا گیا، TASK-001 سے TASK-060 تک، اور پھر میری ہدایت پر AI ایجنٹس نے اسے انجام دیا، تقریباً ہر task کے لیے ایک commit۔ برانچ کی تاریخ میں یہ سلسلہ ایک checklist کی طرح پڑھا جاتا ہے:
TASK-001 deterministic RNG + fixed-timestep simulation loop
TASK-003 tick-based scheduler
TASK-004 entity ID system
TASK-005 input command buffer
TASK-006 time scale / pause handling
TASK-009..029 replace Random everywhere, then audit (029)
TASK-030..035 replace Time.deltaTime and UniTask.Delay
TASK-036..043 sort dictionaries before iterating
TASK-044..050 buffer OnTrigger callbacks
TASK-058..060 test harness, replay system, fixed-point math
ان سب کا حاصل یہ ہے:
- ایک مقررہ 60 Hz لوپ۔ سمولیشن
1/60سیکنڈ کے ticks میں آگے بڑھتی ہے، ایک accumulator سے جسےTime.unscaledDeltaTimeبھرتا ہے، اور ہر frame میں زیادہ سے زیادہ دس ticks، تاکہ ایک سست frame پیچھے سے پکڑنے کا چکر شروع نہ کر سکے۔ frame rate صرف یہ بدلتا ہے کہ گیم کتنی بار ڈرا ہوتی ہے، یہ کبھی نہیں کہ سمولیشن کیا حساب کرتی ہے۔ - seed والی، scope میں بٹی randomness۔ ایک player seed، جو 13 آزاد scopes میں تقسیم ہے (spawning، abilities، drops، enemies، projectiles، props وغیرہ)۔ ایک scope میں کی گئی قرعہ اندازی کبھی دوسرے scope کے سلسلے کو نہیں ہلاتی، اس لیے drop کوڈ میں ایک random call کا اضافہ یہ نہیں بدلتا کہ کون سے دشمن پیدا ہوں گے۔
- ٹائمرز کی جگہ ticks۔ تاخیریں ایک tick scheduler بن جاتی ہیں، اس لیے "0.5 سیکنڈ بعد" کا مطلب ہر مشین پر "tick N+30 پر" ہوتا ہے۔
- ترتیب وار حل۔ Unity trigger callbacks کو کسی متعین ترتیب کے بغیر چلاتا ہے۔ اب callbacks صرف ٹکراؤ کو درج کرتے ہیں؛ سمولیشن tick کے اندر اس buffer کو entity ID کے حساب سے ترتیب دے کر حل کرتی ہے۔ state بدلنے والے dictionary loops ترتیب شدہ انداز میں iterate ہوتے ہیں۔
- fixed-point ریاضی۔
Fixed64ایکlongمیں رکھی گئی Q32.32 قدر ہے، اور اس کے اوپرFixedVector2اورFixedQuaternion، تاکہ پوزیشنز اس پر منحصر نہ ہوں کہ کوئی خاص CPU float کو کیسے round کرتا ہے۔ - ثبوت کے اوزار۔ ایک replay recorder اور player، ایک
StateSnapshot، اور ایکDeterminismTestHarnessجو ایک ہی inputs دو بار چلا کر state کا موازنہ کرتا ہے۔
پہلی کوشش: سرخ حالت میں مرج، پھر revert
#155 میں TASK-001 سے TASK-060 تک سب شامل تھا۔ اس کی برانچ کی تاریخ دکھاتی ہے کہ
اس کی 86 میں سے 85 commits پر 17 دسمبر کی تاریخ ہے۔ یہ 18 دسمبر کو UTC 14:47 پر
dev میں مرج ہوئی۔
مرج commit کی اپنی CI رن بتاتی ہے کہ کیا غلط تھا:
Tests failed with exit code: 2
EditMode tests completed with failures (Exit code: 2)
اس سے پہلے والی commit پر dev سبز تھا۔ اگلی صبح UTC 04:27 پر مرج شدہ حالت کو
ایک dev_backup برانچ پر push کیا گیا، اور چند سیکنڈ بعد dev کو revert کر دیا
گیا (commit a3523ba32، بالکل الٹ: انہی 273 فائلوں میں +2,192/-28,393)۔ یہ پل
ریکوئسٹ کے بغیر، براہِ راست گیا۔ revert پر push CI سبز آئی۔
میں یہ نہیں بتا سکتا کہ کون سے ٹیسٹ ناکام ہوئے۔ اس دور کے GitHub رن لاگز کی میعاد ختم ہو چکی ہے (API جواب میں HTTP 410 دیتا ہے)، اور صرف annotations باقی ہیں۔ میں یادداشت سے ٹیسٹوں کے نام گھڑنے والا نہیں۔
میں یہ بتا سکتا ہوں کہ revert نے کس چیز کی حفاظت کی۔ 273 فائلوں پر مشتمل ایک
تبدیلی، جو گیم کے ہر حساب کا طریقہ بدلتی ہے، اگر integration برانچ پر سرخ پڑی
رہے تو اپنے پیچھے ہر دوسری تبدیلی کو روک دیتی ہے۔ پہلے snapshot لینے کا مطلب تھا کہ
کچھ ضائع نہیں ہوا؛ revert کا مطلب تھا کہ dev دوبارہ ریلیز کے قابل ہو گیا، جبکہ
کام وہیں جاری رہا جہاں اسے ہونا چاہیے تھا، اپنی برانچ پر۔
دونوں کوششوں کے درمیان
برانچ پر کام چند گھنٹوں میں دوبارہ شروع ہو گیا۔ revert کے بعد پہلی commit نے، 19
دسمبر کو، projectiles کی عمر درست کی جو نئی tick پر مبنی timing کی وجہ سے کٹ رہی
تھی: 2.0 سیکنڈ کی عمر float ریاضی میں 119.999... ticks بنتی ہے، اور ایک (uint)
cast نے اسے 120 کے بجائے 119 بنا دیا، یوں projectiles ایک tick پہلے ختم ہو جاتے
تھے۔ وہاں سے کام دو نئی برانچوں میں پھیلا، feature/playmode-tests اور
feature/more-playmode-tests، جنہوں نے مین مینو اور مراحل کے بہاؤ کو PlayMode
ٹیسٹس کے تحت لا کر CI سے جوڑا، اور ایک feature/performance-tests برانچ میں، جس
میں frame time، GC allocation اور draw calls کے collectors، baseline موازنہ، اور
stress منظرنامے تھے (25 دسمبر کو #159 کے طور پر playmode برانچ میں مرج ہوئی)۔
خود RNG میں ڈیزائن کی دو تبدیلیاں آئیں۔
ہر tick کا seed اب bits نہیں کھوتا۔ پہلا ورژن ہر قرعہ اندازی کا seed یوں نکالتا تھا:
uint combinedSeed = _baseRng.state ^ (uint)((_currentTick << 16) | (scopeId & 0xFFFF));
32-bit tick کو 16 bits بائیں کھسکانے سے اس کی اوپر کی 16 bits ضائع ہو جاتی ہیں،
اس لیے tick 65,536 (60 Hz پر تقریباً 18 منٹ) بالکل tick 0 جیسا seed دیتا ہے۔
دوسرا ورژن ہر scope کو اس کا اپنا بنیادی seed دیتا ہے، جو ایک مستحکم FNV-1a hash
سے نکالا جاتا ہے (کبھی string.GetHashCode نہیں، جو مختلف runtimes میں مستحکم
نہیں)، اور tick کو کھسکانے کے بجائے گھماتا ہے:
uint combinedSeed = baseSeed ^ (uint)((currentTick << 16) | (currentTick >> 16));
مرکب seeds۔ اب seed کو GLOBAL.SCOPE1.SCOPE2 کی صورت میں لکھا جا سکتا ہے،
مثلاً ABC.HLT.FBL: scope 1 (spawning) کا seed HLT سے، scope 2 (abilities)
کا FBL سے، اور باقی ہر scope ABC سے اخذ ہوتا ہے۔ CANDY123 جیسا سادہ seed اب
بھی کام کرتا ہے۔ مقصد تلاش ہے: ایسا seed ڈھونڈنے کے لیے جس کا spawn سلسلہ بھی اچھا
ہو اور ability سلسلہ بھی، آپ ہر scope کو الگ الگ تلاش کر کے جیتنے والوں کو ملا
سکتے ہیں، بجائے اس کے کہ ہر جوڑی کو تلاش کریں۔
دوسری کوشش: شامل ہو گئی
#160 پچیس دسمبر کو ان سب کے ساتھ مرج ہوئی: 60 tasks، درستیاں، PlayMode اور performance سویٹس، اور مرکب seeds۔
مرج کے وقت اس کی CI بھی سرخ تھی، EditMode اور PlayMode دونوں exit code 2 دے رہے
تھے۔ پہلی کوشش سے فرق کوئی سبز چیک نہیں تھا۔ اس کے بعد کے مہینوں میں یہ چیک تقریباً
کبھی سبز نہیں ہوا: جنوری تا جولائی 2026 کی جن 216 dev push رنز کا ریکارڈ GitHub
کے پاس اب بھی ہے، ان میں سے 3 کامیاب ہوئیں۔ جو CI عموماً سرخ رہے وہ بہت کم بتاتی
ہے، اور تب تک سرخ رنگ مرج کو نہیں روکتا تھا۔ ٹیسٹ سویٹ صرف
اگست 2026 کی پائپ لائن تنظیمِ نو کے بعد ایک
قابلِ اعتماد گیٹ بنا۔
اس سے کیا ممکن ہوا، اور بعد میں کیا آیا
ڈیٹرمنزم بذاتِ خود مقصد نہیں تھا۔ ایک ڈیٹرمنسٹک سمولیشن کو ایک seed اور input log سے دوبارہ چلایا جا سکتا ہے، اور اس replay کو کوئی ایسا فریق جانچ سکتا ہے جو کھلاڑی پر بھروسہ نہیں کرتا۔ آٹھ ماہ بعد یہ ایک ایسا سرور بنا جو جمع کرائی گئی رنز کو دوبارہ سمولیٹ کرتا ہے، اور ایک ہفتہ وار لیڈر بورڈ جسے صرف تصدیق شدہ اسکور ملتے ہیں؛ وہ کہانی The Verified Score میں ہے۔
تین چیزیں براہِ راست اس کام سے نکلیں:
- ایک دوبارہ قابلِ استعمال پیکیج۔ 22 دسمبر کو کھلے issue #158 نے ڈیٹرمنزم سسٹم کو ایک UPM پیکیج میں نکالنے کی تجویز دی۔ یہ اب میرے UTools پیکیجز میں ہے، جہاں سے CandyRush اسے استعمال کرتی ہے۔
- ایک compiler قاعدہ۔ 30 دسمبر کو میں نے
AI-Analyzers شروع کیا، Roslyn
analyzers کا ایک عوامی پیک، ان patterns کے لیے جو مجھے ایجنٹس کے لکھے Unity کوڈ
میں بار بار ملتے تھے۔ ISU4000،
ISimulatable.Simulateکے اندر غیر ڈیٹرمنسٹک APIs، جیسےRandomاورTime.deltaTime، کو error قرار دیتا ہے۔ جو قاعدہ اس تبدیلی میں ہاتھ سے نافذ کیا گیا تھا، وہ اب compiler نافذ کرتا ہے۔ - RNG میں بعد کی ایک درستی۔ دونوں کوششیں ایک ہی tick اور scope سے ہر قرعہ اندازی پر نیا sub-generator بناتی تھیں، اس لیے ایک tick میں ایک scope سے دو قرعہ اندازیاں ایک ہی قدر لوٹاتی تھیں۔ اپریل 2026 میں ہر scope کے call counter نے اسے ٹھیک کیا۔
عمومی سبق
cross-cutting تبدیلی کو پہلے ایک نمبر شدہ فہرست کی صورت میں لکھیں۔ ساٹھ tasks، ہر ایک کی ایک commit، نے 273 فائلوں کی تبدیلی کو task بہ task جانچنے کے قابل بنایا اور واضح کر دیا کہ audit (TASK-029) کو کہاں ہونا چاہیے۔
پہلے snapshot، پھر revert، پھر کام جاری۔ جب کام ایک برانچ پر محفوظ ہو تو سرخ integration برانچ کو revert کرنا سستا ہے۔ اسے سرخ چھوڑ دینا پیچھے آنے والے ہر کسی کے لیے مہنگا ہے۔
جو گیٹ ہمیشہ سرخ رہے وہ گیٹ نہیں۔ دونوں کوششیں سرخ چیکس پر مرج ہوئیں۔ اصل درستی مرج کے وقت زیادہ احتیاط نہیں تھی؛ بلکہ چیک کو بامعنی بنانا تھا۔
seed اخذ کرنا ایک ڈیزائن کا مسئلہ ہے۔ tick کو کھسکانا اور ہر قرعہ اندازی پر نیا seed دینا، دونوں بے ضرر لگتے تھے۔ ایک 65,536 ticks کے بعد خود کو دہراتا ہے؛ دوسرے نے ایک ہی tick کی قرعہ اندازیوں کو یکساں کر دیا۔ scope میں بٹا، مستحکم hash والا، گھماؤ پر مبنی طریقہ ہی وہ ہے جس پر اب replays انحصار کرتے ہیں۔