Uma caverna, todo jogador
Como o TUNL constrói um nível diário a partir de nada além de uma data, e os dois bugs que o quebraram silenciosamente por meses.
TUNL é um jogo de voo em cavernas de empuxo mantido. Você segura para subir, solta para cair, e tenta ir mais longe por um túnel que se estreita do que foi ontem. Todo jogador voa pela mesma caverna no mesmo dia, e no limite da meia-noite UTC ela se torna outra.
Não há arquivo de nível. Nenhum servidor distribui um mapa, nada é baixado, e o jogo não armazena o túnel que acabou de gerar. A caverna é uma função pura da data, e todo o "mundo" são cerca de quarenta linhas de aritmética.
Esse design compra surpreendentemente muito: uma classificação global justa, um replay fantasma que custa poucas centenas de bytes, e uma imagem compartilhável da sua corrida. Ele também impõe uma restrição muito mais dura do que parece à primeira vista, e eu a quebrei duas vezes sem perceber. Este post fala sobre as duas metades.
Um nível que é uma função, não um arquivo
A maioria dos jogos procedurais gera um mundo no início da corrida e o mantém na memória. O TUNL nunca o mantém. O centro e a meia-largura do corredor em qualquer posição do mundo wx são calculados sob demanda a partir de duas ondas senoidais sobrepostas:
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 };
}
Este é o esqueleto. A função real acrescenta mais alguns termos, como a metamorfose de forma das corridas profundas descrita mais abaixo, mas nenhum deles recebe nada além de wx e o dia.
Duas ondas em vez de uma, porque um único seno é imediatamente legível como um ritmo e deixa de ser interessante depois de cerca de dez segundos. Um arco lento e largo mais uma ondulação mais rápida e rasa com uma razão de frequências quase irracional dá longos trechos que nunca se repetem totalmente. A meia-abertura se estreita de cerca de 68% da altura da tela para 33% conforme você avança, e faz isso lentamente: nada no primeiro trecho, que é um salão aberto, depois uma linha reta que só chega ao mínimo depois de aproximadamente a pontuação 1.500.
Nem sempre funcionou assim. O corredor primeiro se estreitava numa curva de raiz quadrada que atingia seu mínimo na pontuação 233, uma rampa rápida e concentrada no início, e chegaram comentários dizendo que o jogo era difícil demais. Então o corredor e a amplitude de suas ondas ganharam seu próprio relógio lento, o g acima. As frequências das ondas ficaram de propósito na velha curva de raiz: sin(wx * f) com um wx grande é muito sensível a f, então reajustar o ritmo de uma frequência no meio de uma corrida embaralha a fase de tudo o que já ficou para trás. Amplitude e largura podem ser desaceleradas com segurança, frequência não.
A propriedade importante não é a forma. É que boundsBase(wx) recebe apenas wx. Pergunte a ela sobre a posição do mundo 40.000 no primeiro quadro de uma corrida e ela responde instantaneamente, de forma idêntica, em qualquer dispositivo, sem ter gerado nada no meio.
Onde entra o dia
A data entra por exatamente duas variáveis nesse trecho: _wavePhase1 e _wavePhase2. A data UTC vira um inteiro como 20260915, que passa por uma cadeia de 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);
}
Datas consecutivas diferem em um, então o hash precisa espalhar bastante ou hoje e amanhã pareceriam vizinhos próximos. A cadeia multiply-xorshift faz isso: 20260915 e 20260916 produzem fases completamente independentes.
As variações são deliberadamente pequenas. A fase sozinha muda onde as curvas caem, mas não como o dia se sente, e eu queria que cada dia tivesse um caráter ligeiramente diferente sem que nenhum dia se afastasse de uma base que levou muito tempo para ser ajustada. Oito por cento é suficiente para ser notado ao longo de uma semana e não o bastante para fazer um dia parecer errado.
Quatro fluxos aleatórios, não um
Obstáculos são um problema separado da forma do corredor. Estalactites, moedas, minas e canhões recebem cada um seu próprio gerador independente, todos derivados do mesmo dia:
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));
Um único fluxo compartilhado teria sido mais simples e teria sido um erro. O posicionamento de moedas pode rejeitar uma posição candidata, por exemplo quando cai perto demais de uma estalactite. Num fluxo compartilhado cada rejeição consome um número e desloca tudo o que é sorteado depois, então uma moeda cujo posicionamento falha moveria silenciosamente a próxima mina. Os sais separados tornam os quatro sistemas realmente independentes em vez de cópias defasadas de uma sequência, e significam que posso mudar como as moedas são posicionadas sem mover uma única mina.
Arquétipos de dia
Fase e variação mudam a forma de um dia. Para mudar seu sabor, a semente também escolhe um de quatro arquétipos, que escalam as curvas de espaçamento já presentes no jogo:
| Arquétipo | Estalactites | Moedas | Minas | Chicanas |
|---|---|---|---|---|
| Clássico | 1.00 | 1.00 | 1.00 | 1.00 |
| Dia de chicanas | 0.85 | 1.00 | 1.00 | 1.35 |
| Corredor de minas | 1.00 | 1.00 | 0.75 | 1.00 |
| Corrida de moedas | 1.15 | 0.72 | 1.15 | 0.80 |
Estes são multiplicadores de espaçamento, então um número abaixo de um significa mais juntos. A regra que defini para a tabela era que nenhuma linha pode ser uniformemente mais difícil ou uniformemente mais fácil que Clássico. Cada arquétipo empurra um botão e puxa outro de volta. A corrida de moedas te dá muito mais moedas, que no TUNL alargam o corredor, mas paga por isso com estalactites mais apertadas e mais minas. Um dia deveria ser lido como um dia diferente, não como uma jogada boa ou ruim.
A restrição por baixo de tudo isso
Aqui está a parte que acabou importando mais do que o próprio gerador.
Como a caverna é compartilhada e a classificação é global, tudo o que decide onde um objeto é colocado precisa ser uma função pura da posição no mundo e do dia. Nada mais pode vazar para dentro. Nem o tamanho da tela do jogador, nem sua pontuação, nem se ele tem um power-up ativo, nem há quanto tempo está jogando.
Isso parece óbvio escrito assim. Na prática, proíbe uma longa lista de coisas que parecem completamente razoáveis enquanto você as escreve, e as violações não se anunciam. Nada trava. O jogo parece certo. A caverna simplesmente, em silêncio, deixou de ser a mesma caverna.
Bug um: seis dispositivos, seis cavernas
As funções de espaçamento que decidem a distância entre obstáculos são curvas de dificuldade, então naturalmente leem a dificuldade atual. As versões originais liam a variável global _prog, que é a dificuldade na posição atual do jogador.
Os laços de geração se parecem com isto:
while (nextStalWx < scrollX + W + margin) { ... }
Eles posicionam obstáculos um pouco além da borda direita da tela. Então o momento em que uma dada posição do mundo tem seu espaçamento sorteado depende de W, a largura da tela. Um dispositivo mais largo alcança mais à frente, sorteia essa posição mais cedo na corrida, e portanto amostra a curva de dificuldade num valor mais baixo de _prog do que um dispositivo estreito faz para exatamente o mesmo pedaço de mundo.
Espaçamento diferente. Caverna diferente. Medi em seis tamanhos de dispositivo e obtive seis cavernas diferentes, divergindo a partir da posição do mundo 938, que é aproximadamente a pontuação 15. Tudo depois desse ponto na classificação diária compartilhada estava sendo comparado entre túneis que não eram o mesmo túnel.
A correção foi passar a posição a ser colocada para cada curva, para que respondam "quão difícil é lá" em vez de "quão difícil é aqui, agora":
function progAt(wx) { return Math.min(Math.sqrt(Math.max(wx, 0) / 14000), 1); }
function prog2At(wx) { return Math.max(wx - 14000, 0) / 40000; }
Havia uma segunda instância, menor, da mesma classe de bug escondida no limite da linha central. WALL_PAD, a margem que mantém o corredor afastado da borda da tela, era de 8 pixels fixos enquanto tudo ao redor escalava com a altura da tela. Numa tela mais alta esse preenchimento fixo é proporcionalmente menor, o que deslocava ligeiramente o ponto de limite, o que bastava para inverter uma única rejeição de posicionamento, o que dividia a caverna. Agora é uma fração de H como todo o resto.
Os dois bugs compartilham uma forma que vale a pena nomear: um valor que é quase uma função pura da posição no mundo, com um contaminante que só importa em hardware que você não possui. Nenhum era encontrável jogando. Ambos estão agora travados por testes que geram a caverna em vários tamanhos de tela e afirmam que os resultados são idênticos byte a byte.
Bug dois: conteúdo que ninguém nunca tinha visto
A segunda falha não tinha nada a ver com determinismo e tudo a ver com não conferir minhas suposições com a realidade.
Depois de uma certa profundidade, todos os botões da geometria do corredor atingem seus limites, porque empurrá-los mais torna o túnel realmente impraticável. Além desse ponto, a única coisa que ainda muda é a velocidade de rolagem, então uma corrida muito longa era uma variável ficando lentamente mais nervosa contra um corredor congelado. Para corrigir isso, construí um conjunto de recursos para corridas profundas: uma metamorfose de forma que redistribui as amplitudes das duas ondas por uma sequência de caracteres semeada (grandes varreduras limpas, picado batendo, quase reto), um pulso de velocidade que faz o ritmo respirar, câmaras amplas que chegam como um respiro, rochas como raras peças de destaque.
Liberei tudo na posição do mundo 54.000, que fica por volta da pontuação 900. Esse número veio da curva de dificuldade, onde o platô começa matematicamente. Era o número certo para a geometria e o número errado para os jogadores.
Quando finalmente reproduzi dados reais de classificação contra o gerador, a resposta foi direta: praticamente ninguém chegava perto. Eu tinha passado semanas construindo conteúdo que nenhum jogador jamais tinha visto, eu incluído.
Então construí uma simulação de red team: 2.400 corridas sem interface em quatro níveis de habilidade calibrados, medindo até onde cada nível realmente chega. O nível que correspondia a jogadores reais alcançou a posição do mundo 30.000 em 0% de 600 corridas. Não "raramente". Nunca. Mesmo depois de eu já ter baixado o portão uma vez, para 30.000, o conteúdo ainda era inalcançável. Era o mesmo erro, uma ordem de grandeza menor.
O portão agora fica em 9.000, por volta da pontuação 150, onde a simulação diz que uma parcela real das corridas realmente chega:
| Nível de habilidade | Corridas que alcançam o portão |
|---|---|
| Médio | 3.3% |
| Bom | 24.7% |
| Especialista | 63.5% |
Isso só foi seguro de fazer porque cada recurso das corridas profundas é limitado em relação à mesma posição sem metamorfose. As divisões de amplitude da metamorfose são limitadas contra a velocidade do corredor, então um trecho metamorfoseado nunca carrega mais que cerca de 3% a mais de energia de oscilação que o não metamorfoseado, e as câmaras só alargam o corredor. Nada disso pode tornar uma dada posição mais difícil que a base ajustada à mão, em qualquer profundidade, e é por isso que mover o portão é uma decisão de oferta e não uma mudança de dificuldade.
Uma peça deliberadamente não se moveu. As minas com viés para o ápice, que se ajustam à linha central no ápice de uma curva onde o corredor já te força, carregam um risco real de parecer que o jogo está trapaceando. Essa fica na profundidade antiga até que alguém realmente a voe num dispositivo. Puxar um risco de justiça não comprovado 750 pontos mais cedo não faz parte de uma correção de ritmo.
O que o determinismo compra
Toda essa restrição paga por três coisas que seriam muito mais difíceis de outra forma.
Um fantasma que não custa quase nada
Competir contra um replay da sua melhor corrida normalmente significa gravar entradas ou o estado dos obstáculos. Aqui a caverna é reproduzível a partir de uma data, então um replay não precisa de nada além da posição vertical da nave ao longo do tempo. Um byte por 60 pixels do mundo, quantizado sobre a altura da tela, o que faz de um fantasma completo algumas centenas de bytes e permite que um fantasma gravado num celular seja reproduzido corretamente num tablet.
Ele é indexado pela distância percorrida em vez do tempo decorrido, o que importa porque um dos tipos de moeda desacelera a rolagem e a devolve ao ritmo ao longo de vários segundos. Um fantasma indexado por tempo se dessincronizaria do túnel no momento em que alguém pegasse uma.
Um cartão de compartilhamento que é uma imagem da corrida
Como o corredor é uma função pura da posição, todo o túnel voado pode ser redesenhado depois, comprimido numa faixa, com o ponto de morte e o recorde pessoal marcados. O cartão é uma imagem da corrida em vez de um distintivo de pontuação, e gerá-lo não precisa de nenhuma gravação, apenas da distância alcançada.
Uma classificação que significa algo
Todos voarem uma caverna idêntica é a base inteira para comparar pontuações. É também o motivo pelo qual a largura de renderização é limitada em vez de preencher a tela: o espaçamento dos obstáculos é fixo na posição do mundo, então um dispositivo muito mais largo veria a caverna compartilhada rolar proporcionalmente mais rápido e enfrentaria os mesmos obstáculos com menos tempo de reação. A sensação vertical é normalizada da mesma forma, com gravidade e empuxo escalados pela altura da tela contra uma referência, de modo que uma trajetória cobre a mesma fração do corredor por segundo em qualquer dispositivo.
Se eu começasse de novo
O gerador foi a parte fácil. Duas ondas e um hash são uma tarde. Tudo o que foi caro veio da promessa embrulhada ao redor, de que a caverna é a mesma, o que transforma cada recurso futuro numa pergunta sobre se ele vaza não determinismo para o posicionamento.
A lição que eu manteria é o formato dos testes. Bugs de determinismo são invisíveis jogando e triviais por asserção. Gerar as primeiras milhares de posições em seis tamanhos de tela e compará-las leva milissegundos e teria pego os dois bugs de dispositivo no dia em que foram escritos. O erro do portão de conteúdo precisava de uma ferramenta totalmente diferente, uma simulação de até onde os jogadores realmente chegam, e a única lição real ali é que um número derivado da sua curva de dificuldade não é um número sobre seus jogadores.
A caverna de hoje está no ar e é reiniciada à meia-noite UTC. Roda no navegador, sem instalar nada: