Μία σπηλιά, κάθε παίκτης
Πώς το TUNL χτίζει μια ημερήσια πίστα από τίποτα άλλο παρά μια ημερομηνία, και τα δύο bugs που τη χάλασαν σιωπηλά για μήνες.
Το TUNL είναι ένα παιχνίδι πτήσης σε σπηλιά όπου κρατάς για ώθηση. Κρατάς για να ανέβεις, αφήνεις για να πέσεις, και προσπαθείς να φτάσεις πιο βαθιά σε ένα τούνελ που στενεύει απ' ό,τι χθες. Κάθε παίκτης πετά την ίδια σπηλιά την ίδια μέρα, και στο όριο των μεσανυχτών UTC γίνεται μια διαφορετική.
Δεν υπάρχει αρχείο πίστας. Κανένας server δεν μοιράζει χάρτη, τίποτα δεν κατεβαίνει, και το παιχνίδι δεν αποθηκεύει το τούνελ που μόλις δημιούργησε. Η σπηλιά είναι καθαρή συνάρτηση της ημερομηνίας, και ολόκληρος ο «κόσμος» είναι περίπου σαράντα γραμμές αριθμητικής.
Αυτός ο σχεδιασμός φέρνει εκπληκτικά πολλά: μια δίκαιη παγκόσμια κατάταξη, ένα replay φαντάσματος που κοστίζει μερικές εκατοντάδες bytes, και μια εικόνα της πτήσης σου για κοινοποίηση. Επιβάλλει επίσης έναν περιορισμό πολύ πιο αυστηρό απ' ό,τι φαίνεται αρχικά, και τον παραβίασα δύο φορές χωρίς να το καταλάβω. Αυτό το άρθρο αφορά και τα δύο μισά.
Μια πίστα που είναι συνάρτηση, όχι αρχείο
Τα περισσότερα διαδικαστικά παιχνίδια δημιουργούν έναν κόσμο στην αρχή και τον κρατούν στη μνήμη. Το 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, που περνά από μια αλυσίδα 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);
}
Διαδοχικές ημερομηνίες διαφέρουν κατά ένα, οπότε το hash πρέπει να σκορπίζει δυνατά, αλλιώς το σήμερα και το αύριο θα έμοιαζαν σχεδόν γειτονικά. Η αλυσίδα πολλαπλασιασμού και 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));
Μια μόνο κοινή ροή θα ήταν πιο απλή και θα ήταν λάθος. Η τοποθέτηση νομισμάτων μπορεί να απορρίψει μια υποψήφια θέση, για παράδειγμα όταν πέφτει πολύ κοντά σε έναν σταλακτίτη. Σε μια κοινή ροή κάθε απόρριψη καταναλώνει έναν αριθμό και μετατοπίζει όλα όσα τραβιούνται μετά, οπότε ένα νόμισμα που αποτυγχάνει στην τοποθέτηση θα μετακινούσε σιωπηλά την επόμενη νάρκη. Τα ξεχωριστά salts κάνουν τα τέσσερα συστήματα πραγματικά ανεξάρτητα και όχι μετατοπισμένα αντίγραφα μιας ακολουθίας, και σημαίνουν ότι μπορώ να αλλάξω το πώς τοποθετούνται τα νομίσματα χωρίς να μετακινήσω ούτε μία νάρκη.
Αρχέτυπα ημέρας
Η φάση και οι διακυμάνσεις αλλάζουν το σχήμα μιας μέρας. Για να αλλάξει η γεύση της, το seed διαλέγει επίσης ένα από τέσσερα αρχέτυπα, που κλιμακώνουν τις καμπύλες αποστάσεων που ήδη υπάρχουν στο παιχνίδι:
| Αρχέτυπο | Σταλακτίτες | Νομίσματα | Νάρκες | Σικέιν |
|---|---|---|---|---|
| Κλασική | 1.00 | 1.00 | 1.00 | 1.00 |
| Μέρα σικέιν | 0.85 | 1.00 | 1.00 | 1.35 |
| Διάδρομος ναρκών | 1.00 | 1.00 | 0.75 | 1.00 |
| Βροχή νομισμάτων | 1.15 | 0.72 | 1.15 | 0.80 |
Αυτοί είναι πολλαπλασιαστές απόστασης, οπότε ένας αριθμός κάτω από το ένα σημαίνει πιο κοντά. Ο κανόνας που έβαλα για τον πίνακα ήταν ότι καμία γραμμή δεν επιτρέπεται να είναι παντού πιο δύσκολη ή παντού πιο εύκολη από την Κλασική. Κάθε αρχέτυπο σπρώχνει έναν μοχλό και τραβά πίσω έναν άλλο. Η Βροχή νομισμάτων σου δίνει πολύ περισσότερα νομίσματα, που στο TUNL ανοίγουν τον διάδρομο, αλλά το πληρώνει με πιο πυκνούς σταλακτίτες και περισσότερες νάρκες. Μια μέρα πρέπει να διαβάζεται ως διαφορετική μέρα, όχι ως καλή ή κακή ζαριά.
Ο περιορισμός κάτω από όλα αυτά
Αυτό είναι το κομμάτι που αποδείχτηκε πιο σημαντικό από την ίδια τη γεννήτρια.
Επειδή η σπηλιά είναι κοινή και η κατάταξη παγκόσμια, οτιδήποτε αποφασίζει πού τοποθετείται ένα αντικείμενο πρέπει να είναι καθαρή συνάρτηση της θέσης στον κόσμο και της μέρας. Τίποτε άλλο δεν επιτρέπεται να διαρρεύσει μέσα. Ούτε το μέγεθος της οθόνης του παίκτη, ούτε το σκορ του, ούτε αν τυχαίνει να έχει ενεργό ένα power-up, ούτε πόση ώρα παίζει.
Γραμμένο ακούγεται προφανές. Στην πράξη απαγορεύει μια μακριά λίστα πραγμάτων που μοιάζουν απολύτως λογικά όσο τα γράφεις, και οι παραβιάσεις δεν ανακοινώνονται. Τίποτα δεν κρασάρει. Το παιχνίδι φαίνεται σωστό. Η σπηλιά απλώς, σιωπηλά, έχει πάψει να είναι η ίδια σπηλιά.
Πρώτο bug: έξι συσκευές, έξι σπηλιές
Οι συναρτήσεις απόστασης που αποφασίζουν πόσο μακριά είναι τα εμπόδια μεταξύ τους είναι καμπύλες δυσκολίας, οπότε φυσικά διαβάζουν την τρέχουσα δυσκολία. Οι αρχικές εκδοχές διάβαζαν το global _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; }
Υπήρχε και μια δεύτερη, μικρότερη περίπτωση της ίδιας κατηγορίας bug κρυμμένη στον περιορισμό της κεντρικής γραμμής. Το WALL_PAD, το περιθώριο που κρατά τον διάδρομο μακριά από την άκρη της οθόνης, ήταν σταθερά 8 pixel ενώ όλα γύρω του κλιμακώνονταν με το ύψος της οθόνης. Σε μια πιο ψηλή οθόνη αυτό το σταθερό περιθώριο είναι αναλογικά μικρότερο, κάτι που μετακινούσε λίγο το σημείο περιορισμού, κάτι που αρκούσε για να αλλάξει μία απόρριψη τοποθέτησης, κάτι που διχοτομούσε τη σπηλιά. Τώρα είναι κλάσμα του H όπως όλα τα άλλα.
Και τα δύο bugs έχουν ένα σχήμα που αξίζει να ονομαστεί: μια τιμή που είναι σχεδόν καθαρή συνάρτηση της θέσης στον κόσμο, με έναν ρύπο που μετράει μόνο σε hardware που δεν έχεις. Κανένα δεν βρισκόταν παίζοντας. Και τα δύο είναι τώρα κλειδωμένα από τεστ που δημιουργούν τη σπηλιά σε αρκετά μεγέθη οθόνης και ελέγχουν ότι τα αποτελέσματα είναι ίδια byte προς byte.
Δεύτερο bug: περιεχόμενο που δεν είχε δει ποτέ κανείς
Η δεύτερη αποτυχία δεν είχε καμία σχέση με τον ντετερμινισμό και είχε να κάνει εξ ολοκλήρου με το ότι δεν έλεγξα τις υποθέσεις μου απέναντι στην πραγματικότητα.
Μετά από ένα βάθος όλοι οι μοχλοί γεωμετρίας του διαδρόμου φτάνουν στα όριά τους, γιατί αν τους σπρώξεις παραπέρα το τούνελ γίνεται πραγματικά αδιάβατο. Πέρα από εκείνο το σημείο το μόνο που αλλάζει ακόμα είναι η ταχύτητα κύλισης, οπότε μια πολύ μακριά πτήση ήταν μία μεταβλητή που γινόταν σιγά σιγά πιο νευρική απέναντι σε έναν παγωμένο διάδρομο. Για να το διορθώσω, έφτιαξα ένα σύνολο λειτουργιών για βαθιές πτήσεις: μια μεταμόρφωση σχήματος που ξαναμοιράζει τα πλάτη των δύο κυμάτων μέσα από μια ακολουθία χαρακτήρων από το seed (μεγάλες καθαρές καμπύλες, κοφτός κυματισμός, σχεδόν ευθεία), έναν παλμό ταχύτητας που κάνει τον ρυθμό να ανασαίνει, φαρδιές αίθουσες που έρχονται ως ανάσα, βράχους ως σπάνια σκηνικά κομμάτια.
Τα κλείδωσα όλα στη θέση 54.000 του κόσμου, περίπου στο σκορ 900. Αυτός ο αριθμός ήρθε από την καμπύλη δυσκολίας, όπου μαθηματικά ξεκινά το πλατό. Ήταν ο σωστός αριθμός για τη γεωμετρία και ο λάθος αριθμός για τους παίκτες.
Όταν τελικά ξανάτρεξα πραγματικά δεδομένα κατάταξης απέναντι στη γεννήτρια, η απάντηση ήταν ωμή: ουσιαστικά κανείς δεν πλησίαζε καν. Είχα περάσει εβδομάδες φτιάχνοντας περιεχόμενο που δεν το είχε δει ούτε ένας παίκτης, ούτε εγώ.
Έτσι έφτιαξα μια προσομοίωση red-team: 2.400 headless πτήσεις σε τέσσερις βαθμονομημένες βαθμίδες δεξιότητας, μετρώντας πόσο μακριά φτάνει πραγματικά κάθε βαθμίδα. Η βαθμίδα που ταίριαζε με τους πραγματικούς παίκτες έφτασε στη θέση 30.000 του κόσμου στο 0% από 600 πτήσεις. Όχι «σπάνια». Ποτέ. Ακόμα και αφού είχα ήδη κατεβάσει το όριο μία φορά, στις 30.000, το περιεχόμενο παρέμενε απρόσιτο. Ήταν το ίδιο λάθος, μία τάξη μεγέθους μικρότερο.
Το όριο βρίσκεται τώρα στις 9.000, περίπου στο σκορ 150, όπου η προσομοίωση λέει ότι φτάνει πράγματι ένα πραγματικό μέρος των πτήσεων:
| Βαθμίδα δεξιότητας | Πτήσεις που φτάνουν στο όριο |
|---|---|
| Μέτριο | 3.3% |
| Καλό | 24.7% |
| Έμπειρο | 63.5% |
Αυτό ήταν ασφαλές μόνο επειδή κάθε λειτουργία βαθιάς πτήσης είναι φραγμένη σε σχέση με την ίδια θέση χωρίς μεταμόρφωση. Οι διαιρέσεις πλάτους της μεταμόρφωσης έχουν όριο ως προς την ταχύτητα του διαδρόμου, ώστε ένα μεταμορφωμένο κομμάτι να μη φέρει ποτέ πάνω από περίπου 3% περισσότερη ενέργεια κυματισμού από το αμεταμόρφωτο, και οι αίθουσες μόνο ανοίγουν τον διάδρομο. Τίποτα από αυτά δεν μπορεί να κάνει μια δεδομένη θέση πιο δύσκολη από τη χειροκίνητα ρυθμισμένη βάση, σε κανένα βάθος, και γι' αυτό η μετακίνηση του ορίου είναι απόφαση προσφοράς και όχι αλλαγή δυσκολίας.
Ένα κομμάτι σκόπιμα δεν μετακινήθηκε. Οι νάρκες με κλίση προς την κορυφή, που έλκονται προς την κεντρική γραμμή στην κορυφή μιας στροφής όπου ο διάδρομος ήδη σε αναγκάζει, έχουν πραγματικό κίνδυνο να διαβαστούν σαν ζαβολιά του παιχνιδιού. Αυτό μένει στο παλιό βάθος μέχρι να το πετάξει κάποιος πραγματικά σε μια συσκευή. Το να τραβήξεις έναν αναπόδεικτο κίνδυνο δικαιοσύνης 750 πόντους νωρίτερα δεν είναι μέρος μιας διόρθωσης ρυθμού.
Τι κερδίζει ο ντετερμινισμός
Όλος αυτός ο περιορισμός πληρώνει για τρία πράγματα που αλλιώς θα ήταν πολύ πιο δύσκολα.
Ένα φάντασμα που δεν κοστίζει σχεδόν τίποτα
Ο αγώνας με ένα replay της καλύτερης πτήσης σου συνήθως σημαίνει καταγραφή εισόδων ή κατάστασης εμποδίων. Εδώ η σπηλιά αναπαράγεται από μια ημερομηνία, οπότε ένα replay δεν χρειάζεται τίποτα εκτός από την κατακόρυφη θέση του σκάφους στον χρόνο. Ένα byte ανά 60 pixel του κόσμου, κβαντισμένο στο ύψος της οθόνης, κάτι που κάνει ένα πλήρες φάντασμα μερικές εκατοντάδες bytes και αφήνει ένα φάντασμα που γράφτηκε σε κινητό να ξαναπαίζεται σωστά σε tablet.
Είναι δεικτοδοτημένο με την απόσταση που διανύθηκε και όχι με τον χρόνο που πέρασε, κάτι που μετράει γιατί ένα από τα είδη νομισμάτων επιβραδύνει την κύλιση και την επαναφέρει σταδιακά μέσα σε αρκετά δευτερόλεπτα. Ένα φάντασμα δεικτοδοτημένο με χρόνο θα αποσυγχρονιζόταν από το τούνελ τη στιγμή που κάποιος έπιανε ένα τέτοιο νόμισμα.
Μια κάρτα κοινοποίησης που είναι εικόνα της πτήσης
Επειδή ο διάδρομος είναι καθαρή συνάρτηση της θέσης, όλο το τούνελ που πέταξες μπορεί να ξανασχεδιαστεί εκ των υστέρων, συμπιεσμένο σε μια λωρίδα, με το σημείο του θανάτου και το προσωπικό ρεκόρ σημειωμένα. Η κάρτα είναι εικόνα της πτήσης και όχι σήμα σκορ, και για να δημιουργηθεί δεν χρειάζεται καμία καταγραφή, μόνο η απόσταση που έφτασες.
Μια κατάταξη που σημαίνει κάτι
Το ότι όλοι πετούν μια ίδια σπηλιά είναι όλη η βάση για τη σύγκριση των σκορ. Είναι επίσης ο λόγος που το πλάτος απόδοσης έχει όριο αντί να γεμίζει την οθόνη: η απόσταση των εμποδίων είναι σταθερή σε θέση του κόσμου, οπότε μια πολύ πιο φαρδιά συσκευή θα έβλεπε την κοινή σπηλιά να κυλά αναλογικά πιο γρήγορα και θα αντιμετώπιζε τα ίδια εμπόδια με λιγότερο χρόνο αντίδρασης. Η κατακόρυφη αίσθηση κανονικοποιείται με τον ίδιο τρόπο, με τη βαρύτητα και την ώθηση κλιμακωμένες στο ύψος της οθόνης ως προς μια αναφορά, ώστε μια τροχιά να καλύπτει το ίδιο κλάσμα του διαδρόμου ανά δευτερόλεπτο σε κάθε συσκευή.
Αν ξεκινούσα από την αρχή
Η γεννήτρια ήταν το εύκολο κομμάτι. Δύο κύματα και ένα hash είναι ένα απόγευμα. Όλα τα ακριβά ήρθαν από την υπόσχεση που την τυλίγει, ότι η σπηλιά είναι η ίδια, που κάνει κάθε μελλοντική λειτουργία ερώτημα για το αν διαρρέει μη ντετερμινισμό στην τοποθέτηση.
Το μάθημα που θα κρατούσα είναι το σχήμα των τεστ. Τα bugs ντετερμινισμού είναι αόρατα στο παιχνίδι και τετριμμένα σε έναν έλεγχο. Η δημιουργία των πρώτων μερικών χιλιάδων θέσεων σε έξι μεγέθη οθόνης και η σύγκρισή τους παίρνει χιλιοστά του δευτερολέπτου και θα είχε πιάσει και τα δύο bugs συσκευών τη μέρα που γράφτηκαν. Το λάθος με το κλείδωμα του περιεχομένου χρειαζόταν ένα εντελώς διαφορετικό εργαλείο, μια προσομοίωση του πόσο μακριά φτάνουν πραγματικά οι παίκτες, και το μόνο πραγματικό μάθημα εκεί είναι ότι ένας αριθμός που προκύπτει από την καμπύλη δυσκολίας σου δεν είναι αριθμός για τους παίκτες σου.
Η σημερινή σπηλιά είναι ζωντανή και μηδενίζεται τα μεσάνυχτα UTC. Τρέχει στον browser, χωρίς εγκατάσταση: