Una cueva, todos los jugadores

Cómo TUNL construye un nivel diario a partir de nada más que una fecha, y los dos bugs que lo rompieron silenciosamente durante meses.

Devlog · 15 de septiembre de 2026 · actualizado el 23 de septiembre de 2026 para TUNL 17.0 · todas las entradas

TUNL es un juego de vuelo en cuevas de empuje sostenido. Mantienes pulsado para subir, sueltas para caer, e intentas llegar más lejos por un túnel que se estrecha que ayer. Cada jugador vuela por la misma cueva el mismo día, y en el límite de la medianoche UTC se convierte en otra.

No hay archivo de nivel. Ningún servidor reparte un mapa, nada se descarga, y el juego no almacena el túnel que acaba de generar. La cueva es una función pura de la fecha, y todo el "mundo" son unas cuarenta líneas de aritmética.

Ese diseño compra sorprendentemente mucho: una tabla de clasificación global justa, una repetición fantasma que cuesta unos cientos de bytes, y una imagen compartible de tu partida. También impone una restricción mucho más dura de lo que parece al principio, y la rompí dos veces sin darme cuenta. Esta entrada trata de ambas mitades.

Un nivel que es una función, no un archivo

La mayoría de los juegos procedurales generan un mundo al inicio de la partida y lo mantienen en memoria. TUNL nunca lo mantiene. El centro y el semiancho del corredor en cualquier posición del mundo wx se calculan bajo demanda a partir de dos ondas sinusoidales superpuestas:

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 es el esqueleto. La función real añade unos cuantos términos más, como la metamorfosis de forma de las partidas profundas descrita más abajo, pero ninguno toma nada excepto wx y el día.

Dos ondas en vez de una, porque un solo seno se lee de inmediato como un ritmo y deja de ser interesante tras unos diez segundos. Un arco lento y ancho más una ondulación más rápida y poco profunda con una razón de frecuencias casi irracional da largos tramos que nunca se repiten del todo. La semiapertura se estrecha de aproximadamente el 68% de la altura de la pantalla al 33% a medida que avanzas, y lo hace lentamente: nada en absoluto durante el primer tramo, que es una sala abierta, luego una línea recta que solo toca fondo después de más o menos la puntuación 1.500.

No siempre funcionó así. El corredor se estrechaba primero en una curva de raíz cuadrada que llegaba a su mínimo en la puntuación 233, una rampa rápida y concentrada al principio, y llegaron comentarios diciendo que el juego era demasiado difícil. Así que el corredor y la amplitud de sus ondas recibieron su propio reloj lento, el g de arriba. Las frecuencias de las ondas se quedaron a propósito en la vieja curva de raíz: sin(wx * f) con un wx grande es muy sensible a f, así que cambiar el ritmo de una frecuencia a mitad de partida revuelve la fase de todo lo que ya dejaste atrás. La amplitud y el ancho se pueden ralentizar sin riesgo, la frecuencia no.

La propiedad importante no es la forma. Es que boundsBase(wx) solo toma wx. Pregúntale por la posición del mundo 40.000 en el primer fotograma de una partida y responde al instante, de forma idéntica, en cualquier dispositivo, sin haber generado nada entre medias.

Dónde entra el día

La fecha entra a través de exactamente dos variables en ese fragmento: _wavePhase1 y _wavePhase2. La fecha UTC se convierte en un entero como 20260915, que pasa por una cadena 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);
}

Fechas consecutivas difieren en uno, así que el hash tiene que dispersar mucho o hoy y mañana parecerían vecinos cercanos. La cadena multiply-xorshift lo hace: 20260915 y 20260916 producen fases completamente independientes.

Las variaciones son deliberadamente pequeñas. La fase por sí sola cambia dónde caen las curvas pero no cómo se siente el día, y quería que cada día tuviera un carácter ligeramente distinto sin que ningún día se alejara de una base que llevó mucho tiempo ajustar. Un ocho por ciento basta para notarse a lo largo de una semana y no es suficiente para que un día parezca equivocado.

Cuatro flujos aleatorios, no uno

Los obstáculos son un problema separado de la forma del corredor. Estalactitas, monedas, minas y cañones tienen cada uno su propio generador independiente, todos derivados del mismo día:

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

Un único flujo compartido habría sido más simple y habría sido un error. La colocación de monedas puede rechazar una posición candidata, por ejemplo cuando cae demasiado cerca de una estalactita. En un flujo compartido cada rechazo consume un número y desplaza todo lo que se saca después, así que una moneda cuya colocación falla movería silenciosamente la siguiente mina. Las sales separadas hacen que los cuatro sistemas sean realmente independientes en vez de copias desfasadas de una secuencia, y significan que puedo cambiar cómo se colocan las monedas sin mover una sola mina.

Arquetipos de día

La fase y la variación cambian la forma de un día. Para cambiar su sabor, la semilla también elige uno de cuatro arquetipos, que escalan las curvas de espaciado que ya están en el juego:

ArquetipoEstalactitasMonedasMinasChicanas
Clásico1.001.001.001.00
Día de chicanas0.851.001.001.35
Pasillo de minas1.001.000.751.00
Fiebre de monedas1.150.721.150.80

Estos son multiplicadores de espaciado, así que un número por debajo de uno significa más juntos. La regla que fijé para la tabla era que ninguna fila puede ser uniformemente más difícil o uniformemente más fácil que Clásico. Cada arquetipo empuja un mando y retrocede otro. La fiebre de monedas te da muchas más monedas, que en TUNL ensanchan el corredor, pero lo paga con estalactitas más apretadas y más minas. Un día debería leerse como un día distinto, no como una buena o mala tirada.

La restricción que hay debajo de todo esto

Esta es la parte que resultó importar más que el generador en sí.

Como la cueva es compartida y la tabla de clasificación es global, todo lo que decide dónde se coloca un objeto debe ser una función pura de la posición del mundo y del día. Nada más puede filtrarse. Ni el tamaño de pantalla del jugador, ni su puntuación, ni si tiene un potenciador activo, ni cuánto tiempo lleva jugando.

Suena obvio escrito así. En la práctica prohíbe una larga lista de cosas que parecen completamente razonables mientras las escribes, y las violaciones no se anuncian. Nada se cuelga. El juego se ve bien. La cueva simplemente, en silencio, ha dejado de ser la misma cueva.

Bug uno: seis dispositivos, seis cuevas

Las funciones de espaciado que deciden qué tan separados están los obstáculos son curvas de dificultad, así que naturalmente leen la dificultad actual. Las versiones originales leían la variable global _prog, que es la dificultad en la posición actual del jugador.

Los bucles de generación se ven así:

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

Colocan obstáculos un poco más allá del borde derecho de la pantalla. Así que el momento en que una posición dada del mundo recibe su espaciado sorteado depende de W, el ancho de pantalla. Un dispositivo más ancho llega más lejos hacia adelante, sortea esa posición antes en la partida, y por tanto muestrea la curva de dificultad con un valor más bajo de _prog que un dispositivo estrecho para exactamente el mismo trozo de mundo.

Espaciado distinto. Cueva distinta. Lo medí en seis tamaños de dispositivo y obtuve seis cuevas distintas, divergiendo desde la posición del mundo 938, que es aproximadamente la puntuación 15. Todo lo que venía después de ese punto en la tabla de clasificación diaria compartida se comparaba a través de túneles que no eran el mismo túnel.

La corrección fue pasar la posición a colocar a cada curva, para que respondan "qué tan difícil es allí" en vez de "qué tan difícil es aquí, ahora":

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

Había una segunda instancia, más pequeña, de la misma clase de bug escondida en el límite de la línea central. WALL_PAD, el margen que mantiene el corredor lejos del borde de la pantalla, era de 8 píxeles fijos mientras todo a su alrededor escalaba con la altura de la pantalla. En una pantalla más alta ese relleno fijo es proporcionalmente más pequeño, lo que movía ligeramente el punto de límite, lo cual bastaba para invertir un solo rechazo de colocación, lo cual dividía la cueva. Ahora es una fracción de H como todo lo demás.

Ambos bugs comparten una forma que vale la pena nombrar: un valor que es casi una función pura de la posición del mundo, con un contaminante que solo importa en hardware que no posees. Ninguno era encontrable jugando. Ambos están ahora bloqueados por pruebas que generan la cueva en varios tamaños de pantalla y afirman que los resultados son idénticos byte a byte.

Bug dos: contenido que nadie había visto nunca

El segundo fallo no tenía nada que ver con el determinismo y todo que ver con no contrastar mis suposiciones con la realidad.

Pasada cierta profundidad, todos los mandos de la geometría del corredor llegan a sus límites, porque empujarlos más hace el túnel realmente impracticable. Más allá de ese punto lo único que sigue cambiando es la velocidad de desplazamiento, así que una partida muy larga era una sola variable volviéndose lentamente más nerviosa contra un corredor congelado. Para arreglarlo construí un conjunto de funciones para partidas profundas: una metamorfosis de forma que redistribuye las amplitudes de las dos ondas a través de una secuencia de caracteres sembrada (grandes barridos limpios, picado vibrante, casi recto), un pulso de velocidad que hace respirar el ritmo, amplias cámaras que llegan como un respiro, rocas como raras piezas destacadas.

Lo activé todo en la posición del mundo 54.000, que es alrededor de la puntuación 900. Ese número venía de la curva de dificultad, donde la meseta empieza matemáticamente. Era el número correcto para la geometría y el número equivocado para los jugadores.

Cuando por fin reproduje datos reales de la tabla de clasificación contra el generador, la respuesta fue contundente: prácticamente nadie llegaba ni cerca. Había pasado semanas construyendo contenido que ningún jugador había visto jamás, yo incluido.

Así que construí una simulación de red team: 2.400 partidas sin interfaz en cuatro niveles de habilidad calibrados, midiendo hasta dónde llega realmente cada nivel. El nivel que coincidía con los jugadores reales alcanzó la posición del mundo 30.000 en el 0% de 600 partidas. No "raramente". Nunca. Incluso después de haber bajado ya la puerta una vez, a 30.000, el contenido seguía siendo inalcanzable. Era el mismo error, un orden de magnitud más pequeño.

La puerta está ahora en 9.000, alrededor de la puntuación 150, donde la simulación dice que una parte real de las partidas llega de verdad:

Nivel de habilidadPartidas que alcanzan la puerta
Promedio3.3%
Bueno24.7%
Experto63.5%

Esto solo era seguro de hacer porque cada función de partidas profundas está acotada respecto a la misma posición sin metamorfosear. Los reparto de amplitud de la metamorfosis están limitados respecto a la velocidad del corredor, así que un tramo metamorfoseado nunca lleva más de un 3% más de energía de oscilación que el no metamorfoseado, y las cámaras solo ensanchan el corredor. Nada de eso puede hacer una posición dada más difícil que la base ajustada a mano, a ninguna profundidad, razón por la cual mover la puerta es una decisión de oferta y no un cambio de dificultad.

Una pieza deliberadamente no se movió. Las minas con sesgo hacia el ápice, que saltan hacia la línea central en el ápice de una curva donde el corredor ya te obliga, llevan un riesgo real de leerse como que el juego hace trampa. Esa se queda a la profundidad antigua hasta que alguien la vuele realmente en un dispositivo. Adelantar 750 puntos un riesgo de equidad sin probar no forma parte de una corrección de ritmo.

Lo que compra el determinismo

Toda esa restricción paga por tres cosas que de otro modo serían mucho más difíciles.

Un fantasma que casi no cuesta nada

Competir contra una repetición de tu mejor partida normalmente significa grabar entradas o el estado de los obstáculos. Aquí la cueva es reproducible a partir de una fecha, así que una repetición no necesita nada excepto la posición vertical de la nave a lo largo del tiempo. Un byte por 60 píxeles del mundo, cuantizado sobre la altura de la pantalla, lo que convierte un fantasma completo en unos cientos de bytes y permite que un fantasma grabado en un teléfono se reproduzca correctamente en una tablet.

Está indexado por distancia recorrida en vez de tiempo transcurrido, lo cual importa porque uno de los tipos de moneda ralentiza el desplazamiento y lo devuelve a su ritmo a lo largo de varios segundos. Un fantasma indexado por tiempo se desincronizaría del túnel en cuanto alguien recogiera una.

Una tarjeta para compartir que es una imagen de la partida

Como el corredor es una función pura de la posición, todo el túnel recorrido se puede redibujar después, comprimido en una tira, con el punto de muerte y el récord personal marcados. La tarjeta es una imagen de la partida en vez de una insignia de puntuación, y generarla no necesita ninguna grabación, solo la distancia alcanzada.

Una tabla de clasificación que significa algo

Que todos vuelen por una cueva idéntica es toda la base para comparar puntuaciones. También es la razón por la que el ancho de renderizado está limitado en vez de llenar la pantalla: el espaciado de los obstáculos es fijo en la posición del mundo, así que un dispositivo mucho más ancho vería la cueva compartida desplazarse proporcionalmente más rápido y se enfrentaría a los mismos obstáculos con menos tiempo de reacción. La sensación vertical se normaliza de la misma forma, con la gravedad y el empuje escalados por la altura de la pantalla respecto a una referencia, de modo que una trayectoria cubre la misma fracción del corredor por segundo en cualquier dispositivo.

Si empezara de nuevo

El generador era la parte fácil. Dos ondas y un hash son una tarde. Todo lo caro vino de la promesa envuelta alrededor, que la cueva es la misma, lo que convierte cada función futura en una pregunta sobre si filtra no determinismo en la colocación.

La lección que conservaría es la forma de las pruebas. Los bugs de determinismo son invisibles jugando y triviales por aserción. Generar las primeras miles de posiciones en seis tamaños de pantalla y compararlas lleva milisegundos y habría atrapado ambos bugs de dispositivo el día en que se escribieron. El error de la puerta de contenido necesitaba una herramienta completamente distinta, una simulación de hasta dónde llegan realmente los jugadores, y la única lección real ahí es que un número derivado de tu curva de dificultad no es un número sobre tus jugadores.

La cueva de hoy está en línea y se reinicia a medianoche UTC. Funciona en el navegador, sin instalar nada:

Juega la cueva de hoy