← تمام مضامین پر واپس

گیم کو ڈیٹرمنسٹک بنانا، دو کوششوں میں

شائع کردہ:
candyrushdeterminismagent-runevidenceunity

دسمبر 2025 میں CandyRush ایک ایسی Unity گیم نہیں رہی جو اتفاقاً دو بار ایک جیسی دکھائی دیتی ہو، بلکہ ایک ایسی سمولیشن بن گئی جو ایک ہی seed اور inputs ملنے پر ہر بار ایک ہی طرح چلتی ہے۔ اس میں ایک ہفتے کے فاصلے سے دو کوششیں لگیں۔ نیچے دیا گیا ہر عدد GitHub API یا git سے ہے۔

پہلی کوشش: #155دوسری کوشش: #160
عنوانFeature/strict determinismcompound seeds, strict determinism, performance tests
کھلی، مرج ہوئی (UTC)17 دسمبر 20:00، 18 دسمبر 14:4725 دسمبر 09:58، 25 دسمبر 10:16
حجم+28,393/-2,192، 273 فائلیں+50,455/-6,492، 714 فائلیں
Commits86117
شامل کی گئی سطروں میں ٹیسٹ attributes485 [Test]651 [Test]، 168 [UnityTest]، 111 [TestCase]
مرج کے وقت CIEditMode ٹیسٹ ناکام، exit code 2EditMode اور 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 انحصار کرتے ہیں۔