Bir Mağara, Her Oyuncu

TUNL'ün yalnızca bir tarihten günlük seviyeyi nasıl kurduğu ve onu aylarca sessizce bozan iki hata.

Geliştirme günlüğü · 15 Eylül 2026 · TUNL 17.0 için 23 Eylül 2026'da güncellendi · tüm yazılar

TUNL, basılı tutarak itme gücü veren bir mağara uçuş oyunudur. Tırmanmak için basılı tutarsın, düşmek için bırakırsın ve daralan bir tünelde dünden daha uzağa gitmeye çalışırsın. Her oyuncu aynı günde aynı mağarada uçar ve UTC gece yarısı sınırında mağara başka bir mağaraya dönüşür.

Seviye dosyası yok. Hiçbir sunucu harita dağıtmaz, hiçbir şey indirilmez ve oyun az önce ürettiği tüneli saklamaz. Mağara, tarihin saf bir fonksiyonudur ve tüm "dünya" yaklaşık kırk satırlık aritmetiktir.

Bu tasarım şaşırtıcı derecede fazlasını sağlar: adil bir küresel lider tablosu, birkaç yüz bayta mal olan bir hayalet tekrar ve koşunun paylaşılabilir bir resmi. Aynı zamanda ilk bakışta göründüğünden çok daha sert bir kısıtlama getirir ve ben onu fark etmeden iki kez bozdum. Bu yazı her iki yarıyla ilgili.

Dosya değil, fonksiyon olan bir seviye

Çoğu prosedürel oyun bir dünyayı koşu başında üretir ve bellekte tutar. TUNL onu hiç tutmaz. Koridorun merkezi ve yarı genişliği, herhangi bir dünya konumu wx için, üst üste binen iki sinüs dalgasından talep üzerine hesaplanır:

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 };
}

Bu iskelet. Gerçek fonksiyon birkaç terim daha ekler, örneğin aşağıda anlatılan derin koşu şekil dönüşümü gibi, ama hiçbiri wx ve gün dışında bir şey almaz.

Bir dalga yerine iki dalga, çünkü tek bir sinüs hemen ritim olarak okunur ve yaklaşık on saniye sonra ilginç olmaktan çıkar. Yavaş geniş bir yay artı neredeyse irrasyonel bir frekans oranıyla daha hızlı ve sığ bir dalgalanma, hiçbir zaman tam olarak tekrar etmeyen uzun aralıklar verir. Yarı boşluk, ilerledikçe ekran yüksekliğinin yaklaşık %68'inden %33'üne daralır ve bunu yavaşça yapar: ilk aralıkta, ki bu açık bir salondur, hiçbir şey olmaz, sonra yalnızca yaklaşık 1.500 puandan sonra dibe vuran düz bir çizgi gelir.

Her zaman böyle çalışmıyordu. Koridor önce 233 puanda en dar haline ulaşan bir karekök eğrisinde daralıyordu, bu başa yığılmış hızlı bir rampadır ve oyunun çok zor olduğu yönünde geri bildirim geldi. Bu yüzden koridor ve dalga genliği kendi yavaş saatlerini aldı, yukarıdaki g. Dalga frekansları kasıtlı olarak eski karekök eğrisinde kaldı: sin(wx * f), büyük bir wx değerinde f'ye çok duyarlıdır, bu yüzden bir koşunun ortasında bir frekansın temposunu yeniden ayarlamak, zaten arkanda kalan her şeyin fazını karıştırır. Genlik ve genişlik güvenle yavaşlatılabilir, frekans yavaşlatılamaz.

Önemli özellik şekil değil. boundsBase(wx)'in yalnızca wx almasıdır. Ona bir koşunun ilk karesinde 40.000 dünya konumunu sor, arada hiçbir şey üretmeden, herhangi bir cihazda aynı şekilde ve anında yanıtlar.

Günün devreye girdiği yer

Tarih, o kod parçasında tam olarak iki değişken üzerinden girer: _wavePhase1 ve _wavePhase2. UTC tarihi, 20260915 gibi bir tamsayıya dönüşür ve bir hash zincirinden geçer:

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);
}

Ardışık tarihler birer farklıdır, bu yüzden hash güçlü bir şekilde dağıtmalıdır, yoksa bugün ve yarın yakın komşu gibi görünür. Multiply-xorshift zinciri bunu yapar: 20260915 ve 20260916 tamamen ilgisiz fazlar üretir.

Titreşimler kasıtlı olarak küçüktür. Faz tek başına virajların nerede düştüğünü değiştirir ama günün nasıl hissettirdiğini değiştirmez ve ben her günün biraz farklı bir karaktere sahip olmasını, ancak hiçbir günün ayarlanması uzun zaman alan bir temel çizgiden uzaklaşmamasını istedim. Yüzde sekiz, bir hafta boyunca fark edilmeye yeter ve bir günü yanlış hissettirmeye yetmez.

Bir değil, dört rastgele akış

Engeller, koridor şeklinden ayrı bir sorundur. Sarkıtlar, paralar, mayınlar ve toplar her biri kendi bağımsız üretecini alır, hepsi aynı günden türetilir:

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));

Tek bir paylaşılan akış daha basit olurdu ve bir hata olurdu. Para yerleştirme bir aday konumu reddedebilir, örneğin bir sarkıta çok yakın düştüğünde. Paylaşılan bir akışta her red bir sayı tüketir ve sonrasında çekilen her şeyi kaydırır, bu yüzden yerleştirmesi başarısız olan bir para bir sonraki mayını sessizce hareket ettirirdi. Ayrı tuzlar dört sistemi tek bir dizinin faz kaymış kopyaları yerine gerçekten bağımsız kılar ve tek bir mayını kıpırdatmadan paraların nasıl yerleştirildiğini değiştirebileceğim anlamına gelir.

Gün arketipleri

Faz ve titreşim bir günün şeklini değiştirir. Onun lezzetini değiştirmek için tohum ayrıca oyunda zaten bulunan aralık eğrilerini ölçekleyen dört arketipten birini seçer:

ArketipSarkıtlarParalarMayınlarŞikanlar
Klasik1.001.001.001.00
Şikan Günü0.851.001.001.35
Mayın Geçidi1.001.000.751.00
Para Hücumu1.150.721.150.80

Bunlar aralık çarpanlarıdır, bu yüzden birin altındaki bir sayı daha yakın anlamına gelir. Tablo için koyduğum kural, hiçbir satırın Klasik'ten düzgün biçimde daha zor veya daha kolay olmasına izin verilmemesiydi. Her arketip bir düğmeyi ileri iter ve bir diğerini geri çeker. Para Hücumu sana çok daha fazla para verir, TUNL'de bunlar koridoru genişletir, ama bunun bedelini daha sıkı sarkıtlar ve daha fazla mayınla öder. Bir gün iyi ya da kötü bir zar atışı gibi değil, farklı bir gün gibi okunmalı.

Tüm bunların altındaki kısıtlama

Üretecin kendisinden daha önemli olduğu ortaya çıkan kısım burası.

Mağara paylaşıldığı ve lider tablosu küresel olduğu için, bir nesnenin nereye yerleştirileceğine karar veren her şey dünya konumunun ve günün saf bir fonksiyonu olmalıdır. Başka hiçbir şeyin sızmasına izin verilmez. Ne oyuncunun ekran boyutu, ne puanı, ne de tesadüfen aktif bir güçlendirmesi olup olmadığı, ne de ne kadar süredir oynadığı.

Yazıldığında bu bariz görünüyor. Uygulamada, yazarken tamamen makul hissettiren uzun bir şeyler listesini yasaklar ve ihlaller kendilerini duyurmaz. Hiçbir şey çökmez. Oyun doğru görünür. Mağara sadece, sessizce, aynı mağara olmaktan çıkmıştır.

Birinci hata: altı cihaz, altı mağara

Engellerin birbirinden ne kadar uzakta durduğuna karar veren aralık fonksiyonları zorluk eğrileridir, bu yüzden doğal olarak mevcut zorluğu okurlar. Orijinal sürümler global _prog değişkenini okuyordu, bu da oyuncunun mevcut konumundaki zorluktur.

Doğurma döngüleri şöyle görünür:

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

Engelleri ekranın sağ kenarının biraz ötesine yerleştirirler. Bu yüzden belirli bir dünya konumunun aralığının çekildiği an, ekran genişliği olan W'ye bağlıdır. Daha geniş bir cihaz daha ileriye uzanır, o konumu koşuda daha erken çeker ve bu nedenle aynı dünya parçası için dar bir cihazın yaptığından daha düşük bir _prog değerinde zorluk eğrisini örnekler.

Farklı aralık. Farklı mağara. Altı cihaz boyutunda ölçtüm ve yaklaşık 15 puana denk gelen 938 dünya konumundan ayrışan altı farklı mağara elde ettim. Paylaşılan günlük lider tablosunda o noktadan sonraki her şey aynı tünel olmayan tüneller arasında karşılaştırılıyordu.

Düzeltme, yerleştirilen konumu her eğriye geçirmekti, böylece "burada, şimdi ne kadar zor" yerine "orada ne kadar zor" sorusuna yanıt verirler:

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

Merkez çizgisi kısıtlamasında saklanan aynı hata sınıfının ikinci, daha küçük bir örneği vardı. Koridoru ekran kenarından uzak tutan boşluk olan WALL_PAD, etrafındaki her şey ekran yüksekliğiyle ölçeklenirken sabit 8 pikseldi. Daha uzun bir ekranda bu sabit dolgu orantısal olarak daha küçüktür, bu da kısıtlama noktasını hafifçe kaydırdı, bu da tek bir yerleştirme reddini çevirmeye yetti, bu da mağarayı ikiye böldü. Artık her şey gibi H'nin bir kesridir.

Her iki hata da adlandırmaya değer bir şekli paylaşır: dünya konumunun neredeyse saf bir fonksiyonu olan, ama yalnızca sahip olmadığın donanımda önem taşıyan tek bir kirletici içeren bir değer. Hiçbiri oynayarak bulunamazdı. İkisi de artık mağarayı birden fazla ekran boyutunda üreten ve sonuçların bayt bayt aynı olduğunu doğrulayan testlerle kilitli.

İkinci hata: kimsenin hiç görmediği içerik

İkinci başarısızlığın determinizmle hiçbir ilgisi yoktu ve varsayımlarımı gerçeklikle kontrol etmemekle her şeyi ilgiliydi.

Belirli bir derinliğin ötesinde tüm koridor geometrisi düğmeleri tavanlarına ulaşır, çünkü onları daha fazla itmek tüneli gerçekten geçilemez hale getirir. O noktanın ötesinde hâlâ değişen tek şey kaydırma hızıdır, bu yüzden çok uzun bir koşu, donmuş bir koridora karşı yavaşça daha gergin hale gelen tek bir değişkendi. Bunu düzeltmek için derin koşu özelliklerinden oluşan bir set kurdum: iki dalganın genliklerini tohumlanmış bir karakter dizisi (büyük temiz süpürmeler, vuran doğrama, neredeyse düz) üzerinden yeniden dağıtan bir şekil dönüşümü, tempoyu nefes aldıran bir hız nabzı, nefes payı olarak gelen geniş odalar, nadir gösterişli sahneler olarak kayalar.

Hepsini yaklaşık 900 puana denk gelen 54.000 dünya konumunda açtım. Bu sayı, platonun matematiksel olarak başladığı zorluk eğrisinden geldi. Geometri için doğru sayı, oyuncular için yanlış sayıydı.

Sonunda gerçek lider tablosu verilerini üretece karşı yeniden oynattığımda cevap açıktı: pratikte kimse ona yakın bile gelemiyordu. Haftalarımı, ben dahil hiçbir oyuncunun hiç görmediği içerik inşa etmekle geçirmiştim.

Bu yüzden bir kırmızı takım simülasyonu kurdum: dört kalibre edilmiş beceri kademesinde 2.400 arayüzsüz koşu, her kademenin gerçekte ne kadar ileri gittiğini ölçüyor. Gerçek oyunculara denk gelen kademe, 30.000 dünya konumuna 600 koşunun %0'ında ulaştı. "Nadiren" değil. Asla. Kapıyı zaten bir kez 30.000'e indirdikten sonra bile içerik hâlâ ulaşılamazdı. Bu aynı hataydı, bir büyüklük mertebesi daha küçük.

Kapı şimdi 9.000'de, yaklaşık 150 puanda duruyor, simülasyonun koşuların gerçek bir kısmının gerçekten vardığını söylediği yerde:

Beceri kademesiKapıya ulaşan koşular
Ortalama3.3%
İyi24.7%
Uzman63.5%

Bunu yapmak yalnızca her derin koşu özelliği dönüşmemiş aynı konuma göre sınırlandığı için güvenliydi. Dönüşümün genlik bölünmeleri koridor hızına karşı sınırlandırılmıştır, bu yüzden dönüşmüş bir aralık dönüşmemiş olandan hiçbir zaman yaklaşık %3'ten fazla titreşim enerjisi taşımaz ve odalar yalnızca koridoru genişletir. Bunların hiçbiri, herhangi bir derinlikte, belirli bir konumu elle ayarlanmış temel çizgiden daha zor yapamaz ve bu yüzden kapıyı taşımak bir zorluk değişikliği değil, bir arz kararıdır.

Bir parça kasıtlı olarak kıpırdamadı. Bir virajın zirvesinde, koridorun zaten seni oraya zorladığı yerde merkez çizgisine yapışan zirve yanlı mayınlar, oyunun hile yapıyormuş gibi okunma riskini gerçekten taşır. O, biri onu gerçekten bir cihazda uçana kadar eski derinlikte kalır. Kanıtlanmamış bir adalet riskini 750 puan daha erken çekmek bir tempo düzeltmesinin parçası değil.

Determinizmin satın aldığı şey

Tüm bu kısıtlama, aksi halde çok daha zor olacak üç şey için ödeme yapar.

Neredeyse hiçbir şeye mal olmayan bir hayalet

En iyi koşunun bir tekrarıyla yarışmak normalde girdileri veya engel durumunu kaydetmek demektir. Burada mağara bir tarihten yeniden üretilebilir, bu yüzden bir tekrar zaman içindeki geminin dikey konumu dışında hiçbir şeye ihtiyaç duymaz. 60 dünya pikseli başına bir bayt, ekran yüksekliği üzerinden nicelenmiş, bu da tam bir hayaleti birkaç yüz bayt yapar ve bir telefonda kaydedilmiş bir hayaletin bir tablette doğru şekilde tekrarlanmasını sağlar.

Geçen zaman yerine kat edilen mesafeyle indekslenir, bu önemlidir çünkü para türlerinden biri kaydırmayı yavaşlatır ve birkaç saniye içinde geri yükseltir. Zamanla indekslenmiş bir hayalet, biri birini aldığı anda tünelden senkronu kaybederdi.

Koşunun resmi olan bir paylaşım kartı

Koridor konumun saf bir fonksiyonu olduğu için, uçulan tünelin tamamı sonradan yeniden çizilebilir, bir şeride sıkıştırılmış, ölüm noktası ve kişisel en iyi işaretlenmiş olarak. Kart bir puan rozeti yerine koşunun bir resmidir ve onu üretmek hiçbir kayıt gerektirmez, yalnızca ulaşılan mesafe.

Bir anlam taşıyan lider tablosu

Herkesin aynı mağarada uçması, puanları karşılaştırmanın tüm temelidir. Aynı zamanda render genişliğinin ekranı doldurmak yerine sınırlandırılmasının nedenidir: engel aralığı dünya konumunda sabittir, bu yüzden çok daha geniş bir cihaz paylaşılan mağaranın orantılı olarak daha hızlı kaydığını görür ve aynı engellerle daha az tepki süresiyle karşılaşırdı. Dikey his de aynı şekilde normalleştirilir, yerçekimi ve itme gücü bir referansa karşı ekran yüksekliğiyle ölçeklenerek, böylece bir yörünge herhangi bir cihazda saniyede koridorun aynı kesrini kapsar.

Yeniden başlasaydım

Üreteç kolay kısımdı. İki dalga ve bir hash bir öğleden sonradır. Pahalı olan her şey, etrafına sarılan söz, mağaranın aynı olduğu sözünden geldi ve bu, her gelecekteki özelliği yerleştirmeye determinizm dışılık sızdırıp sızdırmadığı sorusuna çevirir.

Tutacağım ders test şeklidir. Determinizm hataları oynayarak görünmezdir ve doğrulamayla önemsizdir. İlk birkaç bin konumu altı ekran boyutunda üretmek ve farklarını almak milisaniyeler sürer ve her iki cihaz hatasını yazıldıkları gün yakalardı. İçerik kapısı hatası tamamen farklı bir araç gerektiriyordu, oyuncuların gerçekte ne kadar ileri gittiğinin simülasyonu, ve oradaki tek gerçek ders, zorluk eğrinden türetilmiş bir sayının oyuncularınla ilgili bir sayı olmadığıdır.

Bugünün mağarası canlı ve UTC gece yarısı sıfırlanır. Tarayıcıda çalışır, kurulum yok:

Bugünün mağarasını oyna