Satu Gua, Setiap Pemain
Bagaimana TUNL membangun level harian hanya dari sebuah tanggal, dan dua bug yang diam-diam merusaknya selama berbulan-bulan.
TUNL adalah game terbang di gua dengan dorongan tahan. Kamu menahan untuk naik, melepas untuk jatuh, dan mencoba melaju lebih jauh di terowongan yang menyempit dibanding kemarin. Setiap pemain terbang di gua yang sama pada hari yang sama, dan pada batas tengah malam UTC gua itu berganti menjadi yang lain.
Tidak ada file level. Tidak ada server yang membagikan peta, tidak ada yang diunduh, dan game tidak menyimpan terowongan yang baru saja dihasilkannya. Gua adalah fungsi murni dari tanggal, dan seluruh "dunia" adalah sekitar empat puluh baris aritmetika.
Desain itu membeli banyak hal yang mengejutkan: papan peringkat global yang adil, replay hantu yang hanya beberapa ratus byte, dan gambar run kamu yang bisa dibagikan. Desain itu juga memaksakan batasan yang jauh lebih keras daripada kelihatannya, dan saya melanggarnya dua kali tanpa sadar. Tulisan ini membahas kedua bagiannya.
Level yang merupakan fungsi, bukan file
Sebagian besar game prosedural menghasilkan dunia saat run dimulai dan menyimpannya di memori. TUNL tidak pernah menyimpannya sama sekali. Pusat dan setengah lebar koridor di setiap posisi dunia wx dihitung sesuai permintaan dari dua gelombang sinus yang saling tumpang tindih:
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 };
}
Ini adalah kerangkanya. Fungsi yang sebenarnya menambahkan beberapa suku lagi, seperti morf bentuk run dalam yang dijelaskan di bawah, tetapi tidak ada yang menerima apa pun selain wx dan hari.
Dua gelombang, bukan satu, karena satu sinus langsung terbaca sebagai ritme dan berhenti menarik setelah sekitar sepuluh detik. Busur lebar yang lambat ditambah riak yang lebih cepat dan dangkal pada rasio frekuensi yang hampir irasional menghasilkan bentangan panjang yang tidak pernah persis berulang. Setengah celah menyempit dari sekitar 68% tinggi layar menjadi 33% seiring kamu semakin dalam, dan melakukannya secara perlahan: tidak ada apa pun untuk bentangan pertama, yang merupakan aula terbuka, lalu garis lurus yang baru mencapai dasar setelah sekitar skor 1.500.
Ini tidak selalu berjalan seperti itu. Koridor awalnya menyempit pada kurva akar kuadrat yang mencapai titik tersempitnya di skor 233, yang merupakan tanjakan cepat yang menumpuk di awal, dan umpan balik datang mengatakan game-nya terlalu sulit. Jadi koridor dan amplitudo gelombangnya mendapat jam lambatnya sendiri, yaitu g di atas. Frekuensi gelombang sengaja tetap pada kurva akar kuadrat lama: sin(wx * f) pada wx yang besar sangat sensitif terhadap f, jadi menyetel ulang tempo sebuah frekuensi di tengah run mengacak fase dari semua yang sudah ada di belakangmu. Amplitudo dan lebar aman untuk diperlambat, frekuensi tidak.
Properti pentingnya bukan bentuknya. Melainkan bahwa boundsBase(wx) hanya menerima wx. Tanyakan padanya tentang posisi dunia 40.000 di frame pertama sebuah run dan ia menjawab seketika, secara identik, di perangkat apa pun, tanpa menghasilkan apa pun di antaranya.
Di mana hari itu masuk
Tanggal masuk melalui tepat dua variabel dalam cuplikan itu: _wavePhase1 dan _wavePhase2. Tanggal UTC menjadi bilangan bulat seperti 20260915, yang dijalankan melalui rantai hash:
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);
}
Tanggal berurutan berbeda satu, jadi hash harus menyebar dengan kuat atau hari ini dan besok akan terlihat seperti tetangga dekat. Rantai multiply-xorshift melakukannya: 20260915 dan 20260916 menghasilkan fase yang sama sekali tidak berhubungan.
Getarannya sengaja dibuat kecil. Fase saja mengubah di mana tikungan jatuh tetapi tidak bagaimana hari itu terasa, dan saya ingin setiap hari memiliki karakter yang sedikit berbeda tanpa ada hari yang menyimpang dari garis dasar yang butuh waktu lama untuk disetel. Delapan persen cukup untuk terasa dalam seminggu dan tidak cukup untuk membuat sebuah hari terasa salah.
Empat aliran acak, bukan satu
Rintangan adalah masalah terpisah dari bentuk koridor. Stalaktit, koin, ranjau, dan meriam masing-masing mendapat generator independennya sendiri, semuanya diturunkan dari hari yang sama:
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));
Satu aliran bersama akan lebih sederhana dan akan menjadi kesalahan. Penempatan koin bisa menolak posisi kandidat, misalnya saat mendarat terlalu dekat dengan stalaktit. Pada aliran bersama setiap penolakan menghabiskan satu angka dan menggeser semua yang ditarik setelahnya, jadi koin yang gagal ditempatkan akan diam-diam menggeser ranjau berikutnya. Salt yang terpisah membuat keempat sistem benar-benar independen alih-alih salinan yang bergeser fase dari satu urutan, dan itu berarti saya bisa mengubah cara koin ditempatkan tanpa menggerakkan satu ranjau pun.
Arketipe hari
Fase dan getaran mengubah bentuk sebuah hari. Untuk mengubah rasanya, seed juga memilih salah satu dari empat arketipe, yang menskalakan kurva jarak yang sudah ada di game:
| Arketipe | Stalaktit | Koin | Ranjau | Chicane |
|---|---|---|---|---|
| Klasik | 1.00 | 1.00 | 1.00 | 1.00 |
| Hari Chicane | 0.85 | 1.00 | 1.00 | 1.35 |
| Lorong Ranjau | 1.00 | 1.00 | 0.75 | 1.00 |
| Serbuan Koin | 1.15 | 0.72 | 1.15 | 0.80 |
Ini adalah pengali jarak, jadi angka di bawah satu berarti lebih rapat. Aturan yang saya tetapkan untuk tabel ini adalah bahwa tidak ada baris yang boleh secara seragam lebih sulit atau lebih mudah daripada Klasik. Setiap arketipe mendorong satu tuas dan menarik yang lain kembali. Serbuan Koin memberimu jauh lebih banyak koin, yang di TUNL melebarkan koridor, tetapi membayarnya dengan stalaktit yang lebih rapat dan lebih banyak ranjau. Sebuah hari seharusnya terbaca sebagai hari yang berbeda, bukan sebagai lemparan yang bagus atau buruk.
Batasan di balik semua ini
Inilah bagian yang ternyata lebih penting daripada generatornya sendiri.
Karena gua itu dibagikan dan papan peringkatnya global, apa pun yang menentukan di mana sebuah objek ditempatkan harus merupakan fungsi murni dari posisi dunia dan hari. Tidak ada hal lain yang boleh merembes masuk. Bukan ukuran layar pemain, bukan skornya, bukan apakah mereka kebetulan punya power-up aktif, bukan berapa lama mereka bermain.
Kedengarannya jelas jika ditulis. Dalam praktiknya, itu melarang daftar panjang hal-hal yang terasa sepenuhnya masuk akal saat kamu menulisnya, dan pelanggarannya tidak mengumumkan diri. Tidak ada yang crash. Game terlihat benar. Gua itu hanya, diam-diam, berhenti menjadi gua yang sama.
Bug satu: enam perangkat, enam gua
Fungsi jarak yang menentukan seberapa jauh rintangan berada adalah kurva kesulitan, jadi tentu saja mereka membaca kesulitan saat ini. Versi asli membaca variabel global _prog, yaitu kesulitan pada posisi pemain saat ini.
Loop spawn terlihat seperti ini:
while (nextStalWx < scrollX + W + margin) { ... }
Mereka menempatkan rintangan sedikit di luar tepi kanan layar. Jadi saat sebuah posisi dunia tertentu mendapat jaraknya diundi bergantung pada W, lebar layar. Perangkat yang lebih lebar menjangkau lebih jauh ke depan, mengundi posisi itu lebih awal dalam run, dan karena itu mengambil sampel kurva kesulitan pada nilai lebih rendah dari _prog dibanding perangkat sempit untuk potongan dunia yang persis sama.
Jarak berbeda. Gua berbeda. Saya mengukurnya di enam ukuran perangkat dan mendapat enam gua berbeda, menyimpang dari posisi dunia 938, yaitu sekitar skor 15. Semua yang ada setelah titik itu di papan peringkat harian bersama dibandingkan di antara terowongan yang bukan terowongan yang sama.
Perbaikannya adalah meneruskan posisi yang sedang ditempatkan ke setiap kurva, sehingga mereka menjawab "seberapa sulit di sana" alih-alih "seberapa sulit di sini, sekarang":
function progAt(wx) { return Math.min(Math.sqrt(Math.max(wx, 0) / 14000), 1); }
function prog2At(wx) { return Math.max(wx - 14000, 0) / 40000; }
Ada instans kedua yang lebih kecil dari kelas bug yang sama yang bersembunyi di penjepit garis tengah. WALL_PAD, margin yang menjaga koridor dari tepi layar, adalah 8 piksel tetap sementara semua di sekitarnya berskala dengan tinggi layar. Pada layar yang lebih tinggi padding tetap itu secara proporsional lebih kecil, yang menggeser titik penjepit sedikit, yang cukup untuk membalik satu penolakan penempatan, yang memecah gua. Sekarang itu adalah pecahan dari H seperti yang lainnya.
Kedua bug berbagi bentuk yang layak disebutkan: nilai yang hampir merupakan fungsi murni dari posisi dunia, dengan satu kontaminan yang hanya penting pada perangkat keras yang tidak kamu miliki. Tidak ada yang bisa ditemukan dengan bermain. Keduanya sekarang terkunci oleh tes yang menghasilkan gua pada beberapa ukuran layar dan menegaskan bahwa hasilnya identik byte demi byte.
Bug dua: konten yang tidak pernah dilihat siapa pun
Kegagalan kedua tidak ada hubungannya dengan determinisme dan semuanya berhubungan dengan tidak memeriksa asumsi saya terhadap kenyataan.
Melewati kedalaman tertentu, semua tuas geometri koridor mencapai batasnya, karena mendorongnya lebih jauh membuat terowongan benar-benar tidak bisa dilalui. Di luar titik itu satu-satunya yang masih berubah adalah kecepatan gulir, jadi run yang sangat panjang adalah satu variabel yang perlahan menjadi lebih gelisah melawan koridor yang beku. Untuk memperbaikinya saya membangun serangkaian fitur run dalam: morf bentuk yang mendistribusikan ulang amplitudo kedua gelombang melalui urutan karakter yang diseed (sapuan besar yang bersih, cacahan berdenyut, hampir lurus), denyut kecepatan yang membuat tempo bernapas, ruang lebar yang datang sebagai jeda, batu besar sebagai adegan langka yang menonjol.
Saya membuka semuanya pada posisi dunia 54.000, yaitu sekitar skor 900. Angka itu berasal dari kurva kesulitan, tempat dataran tinggi dimulai secara matematis. Itu angka yang tepat untuk geometri dan angka yang salah untuk para pemain.
Ketika akhirnya saya memutar ulang data papan peringkat nyata terhadap generator, jawabannya blak-blakan: pada dasarnya tidak ada yang mendekatinya. Saya menghabiskan berminggu-minggu membangun konten yang belum pernah dilihat oleh satu pemain pun, termasuk saya.
Jadi saya membangun simulasi red team: 2.400 run tanpa antarmuka di empat tingkat keterampilan yang dikalibrasi, mengukur seberapa jauh setiap tingkat benar-benar mencapai. Tingkat yang cocok dengan pemain nyata mencapai posisi dunia 30.000 pada 0% dari 600 run. Bukan "jarang". Tidak pernah. Bahkan setelah saya sudah menurunkan gerbang sekali, ke 30.000, konten itu masih tidak terjangkau. Itu kesalahan yang sama, satu tingkat magnitudo lebih kecil.
Gerbang sekarang berada di 9.000, sekitar skor 150, tempat simulasi mengatakan bagian nyata dari run benar-benar tiba:
| Tingkat keterampilan | Run yang mencapai gerbang |
|---|---|
| Rata-rata | 3.3% |
| Baik | 24.7% |
| Ahli | 63.5% |
Ini aman dilakukan hanya karena setiap fitur run dalam dibatasi relatif terhadap posisi yang sama tanpa morf. Pembagian amplitudo morf dibatasi terhadap kecepatan koridor sehingga bentangan yang dimorf tidak pernah membawa lebih dari sekitar 3% energi goyangan lebih banyak daripada yang tidak dimorf, dan ruang hanya melebarkan koridor. Tidak ada yang bisa membuat posisi tertentu lebih sulit daripada garis dasar yang disetel dengan tangan, pada kedalaman berapa pun, itulah sebabnya memindahkan gerbang adalah keputusan pasokan, bukan perubahan kesulitan.
Satu bagian sengaja tidak bergerak. Ranjau yang condong ke puncak, yang menempel ke garis tengah di puncak tikungan tempat koridor sudah memaksamu, membawa risiko nyata terbaca seperti game yang curang. Yang itu tetap di kedalaman lama sampai seseorang benar-benar menerbanginya di perangkat. Menarik risiko keadilan yang belum terbukti 750 poin lebih awal bukan bagian dari perbaikan tempo.
Apa yang dibeli determinisme
Semua batasan itu membayar untuk tiga hal yang kalau tidak akan jauh lebih sulit.
Hantu yang hampir tidak memakan biaya
Berlomba melawan replay run terbaikmu biasanya berarti merekam input atau status rintangan. Di sini gua dapat direproduksi dari sebuah tanggal, jadi replay tidak membutuhkan apa pun kecuali posisi vertikal kapal sepanjang waktu. Satu byte per 60 piksel dunia, dikuantisasi terhadap tinggi layar, yang membuat hantu penuh hanya beberapa ratus byte dan memungkinkan hantu yang direkam di ponsel diputar ulang dengan benar di tablet.
Ia diindeks berdasarkan jarak tempuh alih-alih waktu yang berlalu, yang penting karena salah satu jenis koin memperlambat gulir dan mengembalikannya dalam beberapa detik. Hantu yang diindeks waktu akan tidak sinkron dengan terowongan saat seseorang mengambil satu.
Kartu bagikan yang merupakan gambar dari run
Karena koridor adalah fungsi murni dari posisi, seluruh terowongan yang dilalui dapat digambar ulang setelah kejadian, dikompresi menjadi satu strip, dengan titik kematian dan rekor pribadi ditandai. Kartu ini adalah gambar dari run alih-alih lencana skor, dan menghasilkannya tidak membutuhkan rekaman sama sekali, hanya jarak yang dicapai.
Papan peringkat yang bermakna
Semua orang terbang di gua yang identik adalah seluruh dasar untuk membandingkan skor. Itu juga alasan mengapa lebar render dibatasi alih-alih memenuhi layar: jarak rintangan tetap dalam posisi dunia, jadi perangkat yang jauh lebih lebar akan melihat gua bersama bergulir secara proporsional lebih cepat dan menghadapi rintangan yang sama dengan waktu reaksi lebih sedikit. Nuansa vertikal dinormalisasi dengan cara yang sama, dengan gravitasi dan dorongan diskalakan berdasarkan tinggi layar terhadap sebuah referensi, sehingga sebuah lintasan mencakup pecahan koridor yang sama per detik di perangkat apa pun.
Kalau saya mulai lagi
Generatornya adalah bagian yang mudah. Dua gelombang dan sebuah hash itu satu sore. Semua yang mahal datang dari janji yang terbungkus di sekitarnya, bahwa gua itu sama, yang mengubah setiap fitur di masa depan menjadi pertanyaan apakah ia membocorkan non-determinisme ke penempatan.
Pelajaran yang akan saya simpan adalah bentuk pengujian. Bug determinisme tidak terlihat dengan bermain dan sepele dengan asersi. Menghasilkan beberapa ribu posisi pertama pada enam ukuran layar dan membandingkannya hanya memakan milidetik dan akan menangkap kedua bug perangkat pada hari keduanya ditulis. Kesalahan gerbang konten membutuhkan alat yang sama sekali berbeda, simulasi seberapa jauh pemain benar-benar sampai, dan satu-satunya pelajaran nyata di sana adalah bahwa angka yang diturunkan dari kurva kesulitanmu bukanlah angka tentang para pemainmu.
Gua hari ini sedang berjalan dan diatur ulang pada tengah malam UTC. Berjalan di browser, tanpa instalasi: