하나의 동굴, 모든 플레이어

TUNL이 날짜 하나만으로 일일 레벨을 만드는 방법과, 그것을 몇 달 동안 조용히 망가뜨린 두 가지 버그.

개발 일지 · 2026년 9월 15일 · TUNL 17.0에 맞춰 2026년 9월 23일 업데이트 · 모든 글

TUNL은 누르고 있으면 추진하는 동굴 비행 게임입니다. 누르면 상승하고, 떼면 낙하하며, 어제보다 좁아지는 터널을 더 멀리 나아가려 합니다. 모든 플레이어는 같은 날 같은 동굴을 날고, UTC 자정 경계에서 동굴은 다른 것으로 바뀝니다.

레벨 파일은 없습니다. 어떤 서버도 지도를 나눠주지 않고, 아무것도 다운로드되지 않으며, 게임은 방금 생성한 터널을 저장하지도 않습니다. 동굴은 날짜의 순수 함수이고, "세계" 전체는 약 마흔 줄의 산술입니다.

이 설계는 놀랄 만큼 많은 것을 얻어냅니다. 공정한 글로벌 순위표, 수백 바이트면 되는 고스트 리플레이, 그리고 공유 가능한 런의 그림입니다. 또한 처음 보이는 것보다 훨씬 가혹한 제약을 부과하는데, 저는 알아채지 못한 채 그것을 두 번 어겼습니다. 이 글은 두 부분 모두에 관한 것입니다.

파일이 아니라 함수인 레벨

대부분의 절차적 생성 게임은 런 시작 시 세계를 생성해 메모리에 보관합니다. 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 같은 정수가 되어 해시 체인을 거칩니다.

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

연속된 날짜는 1만큼 차이 나므로 해시는 강하게 흩어놓아야 하며, 그렇지 않으면 오늘과 내일이 가까운 이웃처럼 보일 것입니다. multiply-xorshift 체인이 그것을 해냅니다. 20260915와 20260916은 완전히 무관한 위상을 만들어냅니다.

변동은 의도적으로 작습니다. 위상만으로는 곡선이 오는 위치가 바뀌지만 그날의 느낌은 바뀌지 않으며, 저는 조정하는 데 오랜 시간이 걸린 기준선에서 어떤 날도 벗어나지 않으면서 각 날이 약간 다른 성격을 갖기를 바랐습니다. 8퍼센트는 일주일에 걸쳐 알아차리기에 충분하고, 그날이 잘못됐다고 느끼게 하기에는 부족합니다.

하나가 아닌 네 개의 난수 스트림

장애물은 통로 형태와는 별개의 문제입니다. 종유석, 동전, 지뢰, 대포는 각각 자기만의 독립된 생성기를 가지며, 모두 같은 날에서 파생됩니다.

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

단일 공유 스트림이 더 간단했겠지만 실수였을 것입니다. 동전 배치는 후보 위치를 거부할 수 있는데, 예를 들어 종유석에 너무 가까이 떨어질 때입니다. 공유 스트림에서는 거부할 때마다 숫자 하나를 소모하고 그 이후 뽑히는 모든 것을 이동시키므로, 배치에 실패한 동전이 다음 지뢰를 조용히 움직이게 됩니다. 별개의 솔트는 네 시스템을 하나의 수열의 위상이 어긋난 복사본이 아니라 진정으로 독립적으로 만들며, 지뢰를 하나도 움직이지 않고 동전 배치 방식을 바꿀 수 있다는 뜻입니다.

일일 원형

위상과 변동은 하루의 형태를 바꿉니다. 그 풍미를 바꾸기 위해, 시드는 게임에 이미 있는 간격 곡선의 배율을 조정하는 네 가지 원형 중 하나도 고릅니다.

원형종유석동전지뢰시케인
클래식1.001.001.001.00
시케인의 날0.851.001.001.35
지뢰 관문1.001.000.751.00
동전 러시1.150.721.150.80

이것들은 간격 배율이므로 1 미만의 숫자는 더 가깝다는 뜻입니다. 이 표에 제가 세운 규칙은 어떤 행도 클래식보다 균일하게 더 어렵거나 균일하게 더 쉬워서는 안 된다는 것이었습니다. 모든 원형은 한 손잡이를 밀어 올리고 다른 하나를 되돌립니다. 동전 러시는 훨씬 많은 동전을 주는데, TUNL에서 이는 통로를 넓혀주지만, 그 대가로 더 촘촘한 종유석과 더 많은 지뢰를 치릅니다. 하루는 좋거나 나쁜 뽑기가 아니라 다른 날로 읽혀야 합니다.

이 모든 것 아래에 있는 제약

이것이 생성기 자체보다 더 중요한 것으로 드러난 부분입니다.

동굴이 공유되고 순위표가 글로벌이므로, 객체가 어디에 배치될지를 결정하는 모든 것은 월드 위치와 날짜의 순수 함수여야 합니다. 그 외에는 아무것도 스며들어서는 안 됩니다. 플레이어의 화면 크기도, 점수도, 우연히 파워업이 활성화되어 있는지 여부도, 얼마나 오래 플레이했는지도 안 됩니다.

적어놓으면 당연하게 들립니다. 실제로는 쓰는 동안 완전히 합리적으로 느껴지는 많은 것들을 금지하며, 위반은 스스로 알리지 않습니다. 아무것도 충돌하지 않습니다. 게임은 올바르게 보입니다. 동굴은 그저 조용히 같은 동굴이기를 그만둔 것입니다.

버그 하나: 여섯 기기, 여섯 동굴

장애물 사이 거리를 결정하는 간격 함수는 난이도 곡선이므로 당연히 현재 난이도를 읽습니다. 원래 버전은 전역 _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; }

중심선 클램프에 같은 종류의 버그의 두 번째, 더 작은 사례가 숨어 있었습니다. 통로를 화면 가장자리에서 떨어뜨리는 여백인 WALL_PAD는 주변의 모든 것이 화면 높이에 따라 조정되는 동안 고정된 8픽셀이었습니다. 더 큰 화면에서는 그 고정 패딩이 상대적으로 작아지고, 이는 클램프 지점을 약간 움직였으며, 이는 하나의 배치 거부를 뒤집기에 충분했고, 이는 동굴을 갈라놓았습니다. 이제는 다른 모든 것처럼 H의 일부입니다.

두 버그는 이름 붙일 만한 형태를 공유합니다. 월드 위치의 순수 함수에 거의 가까운 값에, 소유하지 않은 하드웨어에서만 문제가 되는 오염원이 하나 섞여 있었습니다. 어느 쪽도 플레이해서는 찾을 수 없었습니다. 둘 다 이제 여러 화면 크기에서 동굴을 생성하고 결과가 바이트 단위로 동일함을 단언하는 테스트로 고정되어 있습니다.

버그 둘: 아무도 본 적 없는 콘텐츠

두 번째 실패는 결정성과는 아무 관련이 없었고 제 가정을 현실과 대조하지 않은 것과 전적으로 관련이 있었습니다.

어느 깊이를 넘으면 통로 기하 손잡이들이 모두 상한에 도달하는데, 더 밀면 터널이 정말로 통과할 수 없게 되기 때문입니다. 그 지점을 넘으면 계속 변하는 것은 스크롤 속도뿐이므로, 아주 긴 런은 얼어붙은 통로를 상대로 변수 하나가 서서히 더 예민해지는 것이었습니다. 이를 고치기 위해 저는 깊은 런 기능 모음을 만들었습니다. 두 파도의 진폭을 시드된 성격의 연속(크고 깔끔한 쓸기, 두드리는 잘게 쪼개기, 거의 직선)을 통해 재분배하는 형태 변형, 속도가 숨 쉬게 하는 속도 펄스, 숨 돌릴 틈으로 나타나는 넓은 방, 드문 특별 연출로서의 바위입니다.

저는 이 모든 것을 월드 위치 54,000, 점수로 약 900에서 열어두었습니다. 그 숫자는 고원이 수학적으로 시작되는 난이도 곡선에서 나왔습니다. 그것은 기하학에는 맞는 숫자였고 플레이어에게는 틀린 숫자였습니다.

마침내 실제 순위표 데이터를 생성기에 대해 재생했을 때 답은 직설적이었습니다. 사실상 아무도 그 근처에 가지 못하고 있었습니다. 저는 저 자신을 포함해 한 명의 플레이어도 본 적 없는 콘텐츠를 만드는 데 몇 주를 썼던 것입니다.

그래서 저는 레드팀 시뮬레이션을 만들었습니다. 보정된 네 가지 실력 등급에 걸친 2,400번의 헤드리스 런으로, 각 등급이 실제로 얼마나 멀리 도달하는지 측정합니다. 실제 플레이어와 일치하는 등급은 600번의 런 중 0%에서 월드 위치 30,000에 도달했습니다. "드물게"가 아닙니다. 한 번도요. 이미 관문을 한 번 30,000으로 내린 후에도 콘텐츠는 여전히 도달할 수 없었습니다. 그것은 한 자릿수 작을 뿐인 같은 실수였습니다.

관문은 이제 9,000, 점수로 약 150에 있으며, 시뮬레이션에 따르면 실제로 상당한 비율의 런이 도착하는 곳입니다.

실력 등급관문에 도달한 런
평균3.3%
우수24.7%
전문가63.5%

이것이 안전하게 가능했던 것은 모든 깊은 런 기능이 변형되지 않은 같은 위치에 상대적으로 제한되기 때문일 뿐입니다. 변형의 진폭 분배는 통로 속도에 대해 상한이 정해져 있어 변형된 구간이 변형되지 않은 구간보다 약 3% 이상의 흔들림 에너지를 갖는 일이 없으며, 방은 통로를 넓히기만 합니다. 그 어느 것도 어떤 깊이에서도 주어진 위치를 손으로 조정한 기준선보다 어렵게 만들 수 없으며, 그래서 관문을 옮기는 것은 난이도 변경이 아니라 공급 결정입니다.

한 가지 요소는 의도적으로 움직이지 않았습니다. 통로가 이미 당신을 강제하는 커브의 꼭짓점에서 중심선 쪽으로 붙는 꼭짓점 편향 지뢰는 게임이 속임수를 쓰는 것처럼 읽힐 실제 위험을 안고 있습니다. 그것은 누군가가 실제 기기에서 날아보기 전까지 이전 깊이에 남아 있습니다. 입증되지 않은 공정성 위험을 750포인트 앞당기는 것은 속도 조정 수정의 일부가 아닙니다.

결정성이 사는 것

그 모든 제약은 그렇지 않았다면 훨씬 어려웠을 세 가지를 위해 대가를 치릅니다.

거의 아무것도 들지 않는 고스트

최고 기록 런의 리플레이와 경쟁하려면 보통 입력이나 장애물 상태를 기록해야 합니다. 여기서는 동굴이 날짜로부터 재현 가능하므로, 리플레이는 시간에 따른 배의 수직 위치 외에는 아무것도 필요하지 않습니다. 60 월드 픽셀당 1바이트이며 화면 높이에 대해 양자화되어, 완전한 고스트가 수백 바이트가 되고 휴대폰에서 기록한 고스트가 태블릿에서 올바르게 재생됩니다.

이는 경과 시간이 아니라 이동 거리로 인덱싱되는데, 동전 종류 중 하나가 스크롤을 늦추고 몇 초에 걸쳐 되돌리기 때문에 중요합니다. 시간으로 인덱싱된 고스트는 누군가 그것을 집는 순간 터널과 동기화가 깨질 것입니다.

런의 그림인 공유 카드

통로는 위치의 순수 함수이므로, 날아간 터널 전체를 나중에 다시 그릴 수 있으며, 띠 모양으로 압축하고 사망 지점과 개인 최고 기록을 표시합니다. 카드는 점수 배지가 아니라 런의 그림이며, 그것을 생성하는 데는 기록이 전혀 필요하지 않고 도달한 거리만 있으면 됩니다.

의미 있는 순위표

모두가 동일한 동굴을 나는 것이 점수를 비교하는 모든 기반입니다. 또한 렌더링 너비가 화면을 채우는 대신 상한이 정해진 이유이기도 합니다. 장애물 간격은 월드 위치에서 고정되어 있으므로, 훨씬 넓은 기기는 공유 동굴이 비례해서 더 빠르게 스크롤되는 것을 보고 같은 장애물을 더 짧은 반응 시간으로 마주하게 됩니다. 수직 느낌도 같은 방식으로 정규화되어, 중력과 추력이 기준에 대해 화면 높이로 조정되므로 궤적은 어떤 기기에서든 초당 통로의 같은 비율을 커버합니다.

처음부터 다시 시작한다면

생성기는 쉬운 부분이었습니다. 두 개의 파도와 해시는 오후 한나절 거리입니다. 비싼 것은 모두 그 주위를 감싼 약속, 즉 동굴이 같다는 것에서 나왔으며, 이는 모든 미래 기능을 배치에 비결정성이 새어 들어가는지에 대한 질문으로 바꿔놓습니다.

제가 간직할 교훈은 테스트의 형태입니다. 결정성 버그는 플레이로는 보이지 않고 단언으로는 사소합니다. 여섯 가지 화면 크기에서 처음 수천 개의 위치를 생성해 비교하는 데는 몇 밀리초가 걸리며, 두 기기 버그를 작성된 그날 잡아냈을 것입니다. 콘텐츠 관문 실수에는 전혀 다른 도구, 즉 플레이어가 실제로 얼마나 멀리 가는지에 대한 시뮬레이션이 필요했고, 거기서의 유일한 진짜 교훈은 난이도 곡선에서 파생한 숫자가 플레이어에 대한 숫자가 아니라는 것입니다.

오늘의 동굴은 공개 중이며 UTC 자정에 초기화됩니다. 브라우저에서 실행되며 설치가 필요 없습니다.

오늘의 동굴 플레이