एक गुफा, हर खिलाड़ी

TUNL केवल एक तारीख से दैनिक लेवल कैसे बनाता है, और वे दो बग जिन्होंने इसे महीनों तक चुपचाप तोड़ा।

डेवलॉग · 15 सितंबर 2026 · TUNL 17.0 के लिए 23 सितंबर 2026 को अपडेट किया गया · सभी पोस्ट

TUNL दबाए रखकर थ्रस्ट देने वाला गुफा उड़ान गेम है। तुम चढ़ने के लिए दबाए रखते हो, गिरने के लिए छोड़ते हो, और कल से ज़्यादा दूर एक सिकुड़ती सुरंग में जाने की कोशिश करते हो। हर खिलाड़ी एक ही दिन में एक ही गुफा में उड़ता है, और UTC आधी रात की सीमा पर वह एक अलग गुफा बन जाती है।

कोई लेवल फ़ाइल नहीं है। कोई सर्वर नक्शा नहीं बांटता, कुछ भी डाउनलोड नहीं होता, और गेम उस सुरंग को स्टोर नहीं करता जो उसने अभी बनाई। गुफा तारीख का एक शुद्ध फ़ंक्शन है, और पूरी "दुनिया" लगभग चालीस पंक्तियों का अंकगणित है।

यह डिज़ाइन हैरान करने वाला बहुत कुछ खरीदता है: एक निष्पक्ष वैश्विक लीडरबोर्ड, कुछ सौ बाइट का घोस्ट रीप्ले, और तुम्हारे रन की एक साझा करने लायक तस्वीर। यह एक ऐसी बाधा भी लगाता है जो पहली नज़र में दिखने से कहीं ज़्यादा सख़्त है, और मैंने इसे बिना ध्यान दिए दो बार तोड़ा। यह पोस्ट दोनों हिस्सों के बारे में है।

एक लेवल जो फ़ंक्शन है, फ़ाइल नहीं

ज़्यादातर प्रोसीजरल गेम रन की शुरुआत में एक दुनिया बनाते हैं और उसे मेमोरी में रखते हैं। TUNL उसे बिल्कुल नहीं रखता। किसी भी वर्ल्ड पोज़िशन wx पर कॉरिडोर का केंद्र और आधी चौड़ाई दो ओवरलैप होती साइन तरंगों से मांग पर गणना की जाती हैं:

function boundsBase(wx) {
  const p = Math.min(Math.sqrt(wx / 14000), 1);            // wave frequency ramp, sqrt eased
  const g = Math.min(Math.max(wx - 3000, 0) / 90000, 1);   // corridor ramp, slow and linear
  const wA1 = lerp(H * 0.07,  H * 0.12,  g);       // slow wide arc
  const wA2 = lerp(H * 0.035, H * 0.055, g);       // faster shallow ripple
  const wF1 = lerp(0.0025,    0.0048,    p);
  const wF2 = lerp(0.0060,    0.0115,    p);
  const hg  = lerp(H * 0.34,  H * 0.163, g);       // half-gap: 68% -> 33% of the height

  const raw = H / 2
    + wA1 * Math.sin(wx * wF1 + _wavePhase1)
    + wA2 * Math.sin(wx * wF2 + 1.57 + _wavePhase2);

  const cy = clamp(raw, hg + WALL_PAD, H - hg - WALL_PAD);
  return { top: cy - hg, bot: cy + hg };
}

यह कंकाल है। असली फ़ंक्शन कुछ और पद जोड़ता है, जैसे नीचे बताया गया गहरे रन का आकार-रूपांतरण, लेकिन उनमें से कोई भी wx और दिन के अलावा कुछ नहीं लेता।

एक तरंग की बजाय दो, क्योंकि एक अकेली साइन तुरंत एक लय के रूप में पढ़ी जाती है और लगभग दस सेकंड बाद दिलचस्प नहीं रहती। एक धीमा चौड़ा चाप और लगभग अपरिमेय आवृत्ति अनुपात पर एक तेज़ उथली लहर मिलकर ऐसे लंबे हिस्से देते हैं जो कभी पूरी तरह दोहराए नहीं जाते। आधा अंतर स्क्रीन की ऊंचाई के लगभग 68% से सिकुड़कर 33% हो जाता है जैसे-जैसे तुम आगे जाते हो, और यह धीरे-धीरे होता है: पहले हिस्से में कुछ भी नहीं, जो एक खुला हॉल है, फिर एक सीधी रेखा जो लगभग स्कोर 1,500 के बाद ही तली तक पहुंचती है।

यह हमेशा ऐसे काम नहीं करता था। कॉरिडोर पहले एक वर्गमूल वक्र पर सिकुड़ता था जो स्कोर 233 पर अपनी सबसे संकरी अवस्था तक पहुंचता था, जो शुरू में केंद्रित एक तेज़ रैंप है, और प्रतिक्रिया आई कि गेम बहुत ज़्यादा कठिन है। इसलिए कॉरिडोर और उसकी तरंग के आयाम को अपनी धीमी घड़ी मिली, ऊपर वाला g। तरंग की आवृत्तियां जानबूझकर पुराने वर्गमूल वक्र पर रहीं: sin(wx * f), जब wx बड़ा हो, तब f के प्रति बहुत संवेदनशील होता है, इसलिए रन के बीच में किसी आवृत्ति की गति दोबारा तय करने से तुम्हारे पीछे पहले से मौजूद हर चीज़ की फ़ेज़ गड़बड़ा जाती है। आयाम और चौड़ाई को सुरक्षित रूप से धीमा किया जा सकता है, आवृत्ति को नहीं।

महत्वपूर्ण गुण आकार नहीं है। वह यह है कि boundsBase(wx) केवल wx लेता है। उससे रन के पहले फ्रेम पर वर्ल्ड पोज़िशन 40,000 के बारे में पूछो और वह तुरंत, किसी भी डिवाइस पर एक जैसा, बीच में कुछ भी बनाए बिना जवाब देता है।

जहां दिन आता है

तारीख उस स्निपेट में ठीक दो वेरिएबल के ज़रिए आती है: _wavePhase1 और _wavePhase2। UTC तारीख 20260915 जैसे पूर्णांक में बदल जाती है, जो एक हैश श्रृंखला से गुज़रता है:

function seedDailyVariety(dayInt) {
  let h = Math.imul(dayInt ^ 0x9e3779b9, 0x45d9f3b) >>> 0;
  h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0;
  h = (h ^ (h >>> 16)) >>> 0;
  _wavePhase1 = (h % 6283) / 1000;                 // 0 .. 2pi

  h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0;
  _wavePhase2 = (h % 6283) / 1000;

  _waveJitterA  = 1 + (draw() * 2 - 1) * 0.08;     // +/- 8% amplitude
  _waveJitterF  = 1 + (draw() * 2 - 1) * 0.08;     // +/- 8% frequency
  _dayArchetype = Math.floor(draw() * DAY_ARCHETYPES.length);
}

लगातार तारीखें एक से भिन्न होती हैं, इसलिए हैश को ज़ोर से बिखेरना होगा वरना आज और कल क़रीबी पड़ोसी दिखेंगे। multiply-xorshift श्रृंखला यह करती है: 20260915 और 20260916 पूरी तरह असंबंधित फ़ेज़ पैदा करते हैं।

उतार-चढ़ाव जानबूझकर छोटे हैं। अकेली फ़ेज़ मोड़ों के गिरने की जगह बदलती है लेकिन दिन के एहसास को नहीं, और मैं चाहता था कि हर दिन का चरित्र थोड़ा अलग हो, बिना किसी दिन के उस आधार रेखा से भटके जिसे ट्यून करने में लंबा समय लगा। आठ प्रतिशत एक हफ़्ते में महसूस करने के लिए काफ़ी है और किसी दिन को ग़लत लगाने के लिए काफ़ी नहीं।

चार यादृच्छिक धाराएं, एक नहीं

बाधाएं कॉरिडोर के आकार से अलग समस्या हैं। स्टैलेक्टाइट, सिक्के, माइनें और तोपें हर एक को अपना स्वतंत्र जनरेटर मिलता है, सब एक ही दिन से निकले:

rngStal   = makeRngStream(Math.imul(dayInt ^ 0x5741, 0x2545F491));
rngCoin   = makeRngStream(Math.imul(dayInt ^ 0xC01D, 0x9E3779B1));
rngMine   = makeRngStream(Math.imul(dayInt ^ 0x4D19, 0x85EBCA6B));
rngCannon = makeRngStream(Math.imul(dayInt ^ 0xCA77, 0xC2B2AE35));

एक अकेली साझा धारा ज़्यादा सरल होती और एक ग़लती होती। सिक्का प्लेसमेंट किसी उम्मीदवार स्थिति को अस्वीकार कर सकता है, उदाहरण के लिए जब वह स्टैलेक्टाइट के बहुत पास गिरता है। साझा धारा पर हर अस्वीकृति एक संख्या खर्च करती है और उसके बाद निकाली जाने वाली हर चीज़ को खिसका देती है, इसलिए एक सिक्का जिसका प्लेसमेंट विफल हो वह अगली माइन को चुपचाप हिला देगा। अलग नमक चारों प्रणालियों को एक क्रम की फ़ेज़-खिसकी प्रतियों की बजाय सचमुच स्वतंत्र बनाते हैं, और इसका मतलब है कि मैं एक भी माइन हिलाए बिना सिक्कों के रखे जाने का तरीका बदल सकता हूं।

दिन के आदर्श रूप

फ़ेज़ और उतार-चढ़ाव एक दिन का आकार बदलते हैं। उसका स्वाद बदलने के लिए, सीड चार आदर्श रूपों में से एक भी चुनता है, जो गेम में पहले से मौजूद दूरी वक्रों को स्केल करते हैं:

आदर्श रूपस्टैलेक्टाइटसिक्केमाइनेंचिकेन
क्लासिक1.001.001.001.00
चिकेन डे0.851.001.001.35
माइन गौंटलेट1.001.000.751.00
सिक्का रश1.150.721.150.80

ये दूरी गुणक हैं, इसलिए एक से नीचे की संख्या का मतलब ज़्यादा पास-पास है। तालिका के लिए जो नियम मैंने तय किया वह यह था कि कोई पंक्ति क्लासिक से एकसमान रूप से कठिन या एकसमान रूप से आसान नहीं हो सकती। हर आदर्श रूप एक नॉब को आगे धकेलता है और दूसरे को पीछे खींचता है। सिक्का रश तुम्हें कहीं ज़्यादा सिक्के देता है, जो TUNL में कॉरिडोर को चौड़ा करते हैं, लेकिन इसकी क़ीमत वह और कसे हुए स्टैलेक्टाइट और ज़्यादा माइनों से चुकाता है। एक दिन एक अलग दिन की तरह पढ़ा जाना चाहिए, न कि अच्छे या बुरे पासे के फेंक की तरह।

इस सबके नीचे की बाधा

यह वह हिस्सा है जो जनरेटर से भी ज़्यादा मायने रखने वाला निकला।

क्योंकि गुफा साझा है और लीडरबोर्ड वैश्विक है, जो कुछ भी तय करता है कि कोई वस्तु कहां रखी जाए वह वर्ल्ड पोज़िशन और दिन का शुद्ध फ़ंक्शन होना चाहिए। और कुछ भी रिसने की अनुमति नहीं है। न खिलाड़ी की स्क्रीन का आकार, न उसका स्कोर, न यह कि संयोग से उसके पास कोई पावर-अप सक्रिय है या नहीं, न यह कि वह कितनी देर से खेल रहा है।

लिखा हुआ यह स्पष्ट लगता है। व्यवहार में यह उन चीज़ों की एक लंबी सूची को मना करता है जो लिखते समय पूरी तरह उचित लगती हैं, और उल्लंघन ख़ुद की घोषणा नहीं करते। कुछ भी क्रैश नहीं होता। गेम सही दिखता है। गुफा बस, चुपचाप, वही गुफा रहना बंद कर चुकी है।

बग एक: छह डिवाइस, छह गुफाएं

जो दूरी फ़ंक्शन तय करते हैं कि बाधाएं कितनी दूर हैं वे कठिनाई वक्र हैं, इसलिए स्वाभाविक रूप से वे वर्तमान कठिनाई पढ़ते हैं। मूल संस्करण वैश्विक _prog पढ़ते थे, जो खिलाड़ी की वर्तमान स्थिति पर कठिनाई है।

स्पॉन लूप ऐसे दिखते हैं:

while (nextStalWx < scrollX + W + margin) { ... }

वे बाधाओं को स्क्रीन के दाएं किनारे से थोड़ा आगे रखते हैं। इसलिए किसी दिए गए वर्ल्ड पोज़िशन की दूरी कब निकाली जाती है यह W, स्क्रीन की चौड़ाई पर निर्भर करता है। एक चौड़ा डिवाइस और आगे तक पहुंचता है, उस पोज़िशन को रन में पहले निकालता है, और इसलिए दुनिया के ठीक उसी टुकड़े के लिए एक संकरे डिवाइस की तुलना में कठिनाई वक्र को कम _prog पर नमूना करता है।

अलग दूरी। अलग गुफा। मैंने इसे छह डिवाइस आकारों पर मापा और छह अलग गुफाएं मिलीं, जो वर्ल्ड पोज़िशन 938 से भटकती हैं, यानी लगभग स्कोर 15। साझा दैनिक लीडरबोर्ड पर उस बिंदु के बाद की हर चीज़ ऐसी सुरंगों के बीच तुलना की जा रही थी जो एक ही सुरंग नहीं थीं।

समाधान था रखी जा रही स्थिति को हर वक्र में पास करना, ताकि वे "वहां कितना कठिन है" का जवाब दें बजाय "यहां, अभी कितना कठिन है" के:

function progAt(wx)  { return Math.min(Math.sqrt(Math.max(wx, 0) / 14000), 1); }
function prog2At(wx) { return Math.max(wx - 14000, 0) / 40000; }

उसी क़िस्म के बग का एक दूसरा, छोटा उदाहरण केंद्र-रेखा क्लैंप में छिपा था। WALL_PAD, वह मार्जिन जो कॉरिडोर को स्क्रीन के किनारे से दूर रखता है, एक तय 8 पिक्सेल था जबकि उसके आसपास सब कुछ स्क्रीन की ऊंचाई के साथ स्केल होता था। एक ऊंची स्क्रीन पर वह तय पैडिंग अनुपात में छोटी होती है, जिसने क्लैंप बिंदु को थोड़ा खिसकाया, जो एक प्लेसमेंट अस्वीकृति को पलटने के लिए काफ़ी था, जिसने गुफा को बांट दिया। अब यह बाकी सब चीज़ों की तरह H का एक अंश है।

दोनों बग एक ऐसा आकार साझा करते हैं जिसका नाम लेना बनता है: एक मान जो वर्ल्ड पोज़िशन का लगभग शुद्ध फ़ंक्शन है, एक प्रदूषक के साथ जो केवल उस हार्डवेयर पर मायने रखता है जो तुम्हारे पास नहीं है। खेलकर कोई भी नहीं मिल सकता था। दोनों अब ऐसे टेस्ट से बंद हैं जो कई स्क्रीन आकारों पर गुफा बनाते हैं और दावा करते हैं कि नतीजे बाइट-दर-बाइट एक जैसे हैं।

बग दो: वह सामग्री जिसे किसी ने कभी नहीं देखा

दूसरी विफलता का नियतत्ववाद से कोई लेना-देना नहीं था और इससे सब कुछ लेना-देना था कि मैंने अपनी धारणाओं को हक़ीक़त से नहीं जांचा।

एक निश्चित गहराई के बाद कॉरिडोर ज्यामिति के सभी नॉब अपनी सीमा पर पहुंच जाते हैं, क्योंकि उन्हें और धकेलने से सुरंग सचमुच अगम्य हो जाती है। उस बिंदु के आगे केवल स्क्रॉल गति ही बदलती रहती है, इसलिए एक बहुत लंबा रन एक जमे हुए कॉरिडोर के सामने एक वेरिएबल का धीरे-धीरे ज़्यादा घबराया होना था। इसे ठीक करने के लिए मैंने गहरे-रन की सुविधाओं का एक सेट बनाया: एक आकार रूपांतरण जो दो तरंगों के आयामों को सीड किए गए चरित्रों के अनुक्रम (बड़े साफ़ स्वीप, धड़कता कटाव, लगभग सीधा) के ज़रिए फिर से बांटता है, एक गति स्पंद जो गति को सांस लेने देता है, चौड़े कक्ष जो राहत के रूप में आते हैं, दुर्लभ विशेष दृश्यों के रूप में बड़ी चट्टानें।

मैंने यह सब वर्ल्ड पोज़िशन 54,000 पर, जो लगभग स्कोर 900 है, गेट किया। वह संख्या कठिनाई वक्र से आई, जहां पठार गणितीय रूप से शुरू होता है। वह ज्यामिति के लिए सही संख्या थी और खिलाड़ियों के लिए ग़लत संख्या।

जब मैंने आख़िरकार असली लीडरबोर्ड डेटा को जनरेटर के ख़िलाफ़ दोबारा चलाया, तो जवाब सपाट था: मूल रूप से कोई भी उसके आसपास तक नहीं पहुंच रहा था। मैंने हफ़्ते ऐसी सामग्री बनाने में बिताए थे जिसे किसी एक खिलाड़ी ने भी कभी नहीं देखा, मुझे मिलाकर।

तो मैंने एक रेड-टीम सिमुलेशन बनाया: चार कैलिब्रेटेड कौशल स्तरों में 2,400 हेडलेस रन, यह मापते हुए कि हर स्तर वास्तव में कितनी दूर पहुंचता है। असली खिलाड़ियों से मेल खाने वाला स्तर 600 में से 0% रन में वर्ल्ड पोज़िशन 30,000 तक पहुंचा। "शायद ही कभी" नहीं। कभी नहीं। गेट को एक बार 30,000 तक नीचे लाने के बाद भी सामग्री अभी भी पहुंच से बाहर थी। यह वही ग़लती थी, एक परिमाण-क्रम छोटी।

गेट अब 9,000 पर है, लगभग स्कोर 150, जहां सिमुलेशन कहता है कि रनों का एक असली हिस्सा वास्तव में पहुंचता है:

कौशल स्तरगेट तक पहुंचने वाले रन
औसत3.3%
अच्छा24.7%
एक्सपर्ट63.5%

यह करना केवल इसलिए सुरक्षित था क्योंकि हर गहरे-रन की सुविधा बिना रूपांतरण वाली उसी स्थिति के सापेक्ष सीमित है। रूपांतरण के आयाम-विभाजन कॉरिडोर की गति के विरुद्ध सीमित हैं, इसलिए एक रूपांतरित हिस्सा बिना रूपांतरण वाले से कभी लगभग 3% से ज़्यादा डगमगाहट ऊर्जा नहीं रखता, और कक्ष केवल कॉरिडोर को चौड़ा करते हैं। इनमें से कुछ भी किसी दी गई स्थिति को हाथ से ट्यून की गई आधार रेखा से ज़्यादा कठिन नहीं बना सकता, किसी भी गहराई पर, इसीलिए गेट को हिलाना एक आपूर्ति का निर्णय है, कठिनाई का बदलाव नहीं।

एक हिस्सा जानबूझकर नहीं हिला। शिखर-झुकी माइनें, जो एक मोड़ के शिखर पर केंद्र-रेखा की ओर खिंच जाती हैं जहां कॉरिडोर पहले से तुम्हें मजबूर करता है, गेम के धोखा देने जैसा पढ़े जाने का असली जोखिम रखती हैं। वह पुरानी गहराई पर तब तक रहती है जब तक कोई सचमुच उसे किसी डिवाइस पर उड़ाकर न देखे। एक अप्रमाणित निष्पक्षता जोखिम को 750 अंक पहले खींचना गति सुधार का हिस्सा नहीं है।

नियतत्ववाद क्या खरीदता है

यह पूरी बाधा तीन चीज़ों की क़ीमत चुकाती है जो अन्यथा कहीं ज़्यादा कठिन होतीं।

एक घोस्ट जिसकी लगभग कोई क़ीमत नहीं

तुम्हारे सर्वश्रेष्ठ रन के रीप्ले के ख़िलाफ़ दौड़ने का मतलब सामान्यतः इनपुट या बाधा की स्थिति रिकॉर्ड करना होता है। यहां गुफा एक तारीख से दोबारा बनाई जा सकती है, इसलिए एक रीप्ले को समय के साथ यान की ऊर्ध्वाधर स्थिति के अलावा कुछ नहीं चाहिए। हर 60 वर्ल्ड पिक्सेल पर एक बाइट, स्क्रीन की ऊंचाई पर क्वांटाइज़ किया हुआ, जो एक पूरे घोस्ट को कुछ सौ बाइट बनाता है और फ़ोन पर रिकॉर्ड किए घोस्ट को टैबलेट पर सही ढंग से चलाने देता है।

यह बीते समय की बजाय तय की गई दूरी से इंडेक्स होता है, जो मायने रखता है क्योंकि सिक्कों के प्रकारों में से एक स्क्रॉल को धीमा करता है और उसे कई सेकंड में वापस बढ़ाता है। एक समय-इंडेक्स वाला घोस्ट उसी क्षण सुरंग से सिंक खो देगा जब कोई एक उठाएगा।

एक शेयर कार्ड जो रन की तस्वीर है

क्योंकि कॉरिडोर स्थिति का शुद्ध फ़ंक्शन है, पूरी उड़ाई गई सुरंग को बाद में दोबारा बनाया जा सकता है, एक पट्टी में संपीड़ित, मौत के बिंदु और निजी सर्वश्रेष्ठ के निशान के साथ। कार्ड स्कोर बैज की बजाय रन की तस्वीर है, और इसे बनाने के लिए कोई रिकॉर्डिंग नहीं चाहिए, बस तय की गई दूरी।

एक लीडरबोर्ड जिसका कोई मतलब है

हर किसी का एक जैसी गुफा में उड़ना स्कोर की तुलना का पूरा आधार है। यही वजह है कि रेंडर की चौड़ाई स्क्रीन भरने के बजाय सीमित है: बाधाओं की दूरी वर्ल्ड पोज़िशन में तय है, इसलिए एक बहुत ज़्यादा चौड़ा डिवाइस साझा गुफा को अनुपात में तेज़ी से स्क्रॉल होते देखेगा और उन्हीं बाधाओं का कम प्रतिक्रिया समय के साथ सामना करेगा। ऊर्ध्वाधर एहसास भी उसी तरह सामान्यीकृत है, गुरुत्वाकर्षण और थ्रस्ट को एक संदर्भ के विरुद्ध स्क्रीन की ऊंचाई से स्केल किया जाता है, ताकि किसी भी डिवाइस पर एक प्रक्षेपवक्र प्रति सेकंड कॉरिडोर का वही अंश तय करे।

अगर मैं फिर से शुरू करता

जनरेटर आसान हिस्सा था। दो तरंगें और एक हैश एक दोपहर का काम है। जो कुछ भी महंगा था वह उसके चारों ओर लिपटे वादे से आया, कि गुफा वही है, जो हर भावी सुविधा को इस सवाल में बदल देता है कि क्या वह प्लेसमेंट में अनियतता रिसाती है।

जो सबक मैं रखूंगा वह टेस्टिंग का आकार है। नियतत्ववाद के बग खेलने से अदृश्य होते हैं और एसर्शन से मामूली। छह स्क्रीन आकारों पर पहली कई हज़ार स्थितियां बनाना और उनका अंतर निकालना मिलीसेकंड लेता है और दोनों डिवाइस बग उसी दिन पकड़ लेता जिस दिन वे लिखे गए। सामग्री गेटिंग की ग़लती के लिए एक बिल्कुल अलग औज़ार चाहिए था, खिलाड़ी वास्तव में कितनी दूर पहुंचते हैं इसका सिमुलेशन, और वहां एकमात्र असली सबक यह है कि तुम्हारे कठिनाई वक्र से निकली संख्या तुम्हारे खिलाड़ियों के बारे में संख्या नहीं है।

आज की गुफा लाइव है और UTC आधी रात को रीसेट होती है। यह ब्राउज़र में चलती है, बिना इंस्टॉल:

आज की गुफा खेलें