DEV NOTE · FLIP

요구 방향과 간격으로 "깰 수 있는 터널"을 만드는 법

무작위로 장애물을 뿌리는 러너는 언젠가 반드시 피할 수 없는 배치를 만든다. FLIP의 생성기는 장애물 하나하나에 "있어야 하는 쪽"을 붙이고, 그 쪽이 바뀔 때만 간격을 검사하는 단순한 규칙으로 이 문제를 막는다. 이 글은 그 규칙의 구조와, 글을 준비하며 돌린 시뮬레이션에서 나온 숫자를 정리한다. 그 과정에서 이 규칙이 만든 예상 밖의 부작용도 하나 찾았다.

미리 깔아 두는 커서

생성기는 genY라는 커서를 가지고 있다. 기체의 현재 높이보다 420유닛(약 42m) 위까지 커서를 전진시키며 장애물을 놓고, 기체 아래로 80유닛 이상 지나간 장애물은 배열에서 지운다. 화면에 보이기 전에 배치가 끝나 있으니 생성 비용이 프레임마다 튀지 않고, 메모리에는 늘 수십 개 정도만 남는다.

장애물 한 개를 놓을 때마다 난이도 d = min(1, 높이(m) / 3000)를 계산한다. 종류는 난수 하나로 고른다. 42% 가시, 32% 바, 26% 블록이다. 가시와 바는 왼쪽·오른쪽 중 한 벽을 무작위로 고른다. 그다음 장애물 위로 빈 구간을 둔다. 간격은 그 높이의 목표 속도로 건너기 시간 0.56초 동안 올라가는 거리에 1.7 − 0.6d를 곱한 값이고, 45유닛보다 작아지지는 않는다. 출발 지점에서는 약 105유닛, 3,000m에서는 약 185유닛이다. 속도가 빨라지는 만큼 간격도 넓어지지만, 곱하는 계수가 1.7에서 1.1로 줄어들어 시간으로 따지면 점점 빠듯해진다.

장애물을 "요구 방향"으로 바꿔 생각하기

세 종류의 장애물은 모양이 다르지만 플레이어 입장에서는 결국 "어느 벽에 있어야 하는가"라는 하나의 질문이다. 왼쪽 벽의 가시는 오른쪽을 요구하고, 왼쪽에서 뻗은 바도 틈이 있는 오른쪽을 요구한다. 가운데 블록은 어느 벽이든 괜찮으니 요구 방향이 0이다. 코드에서는 이 값을 req로 부른다. 아래는 1.0.1까지의 코드다.

let side=rng()<.5?-1:1, req=type==='block'?0:-side;
const gap=w.genY-w.lastReqY;
if(type==='block'){ if(gap<crossDist){ type='spike'; side=-w.lastReq; req=w.lastReq; } }
else if(w.lastReq!==0&&req!==w.lastReq&&gap<crossDist){ side=-side; req=-req; }

이렇게 바꿔 놓으면 실행 가능성 문제가 아주 작아진다. 연속한 두 장애물의 요구 방향이 같다면 플레이어는 그냥 그 벽에 머물면 된다. 다르다면 앞 장애물의 위쪽 끝을 지난 뒤 다음 장애물의 아래쪽 끝에 닿기 전에 건너기를 마쳐야 한다. 필요한 높이는 "그 높이의 목표 속도 × 0.56초"이고, 여기에 25% 여유를 곱한 값이 crossDist다. 1.0.1까지는 간격이 그보다 좁으면 생성기가 다음 장애물을 좌우로 뒤집어 같은 쪽을 요구하게 바꿨다(1.0.2에서 밀어 올리기로 바뀌었다. 아래 참고).

블록은 조금 다르게 처리했다(1.0.1까지). 블록은 어느 벽이든 안전하지만 공중은 위험하다. 바로 앞에서 플레이어가 건너는 중일 수 있는 거리 안에 블록이 오면, 블록을 직전 요구 방향과 같은 쪽을 요구하는 가시로 바꾼다. 건너기를 끝낸 플레이어가 머무는 벽은 안전하게 남겨 두는 셈이다.

여유를 1.25배로 잡은 데는 근거가 있다. 공중에 있는 동안 상승 속도는 목표의 80%로 떨어지므로 실제로 건너는 동안 올라가는 거리는 계산보다 짧다. 1.25배 여유와 80% 감속이 겹치면 플레이어는 실제로 필요한 높이의 약 1.5배 구간 안에서 탭할 기회를 얻는다. 충돌 판정도 장애물 사각형을 안쪽으로 2유닛 줄이고 기체 반지름을 1 줄여 계산하므로, 스치는 정도는 살아남는다.

같은 시드는 같은 터널

DAILY RUN이 성립하려면 같은 날짜에 모든 기기에서 같은 터널이 나와야 한다. 난수는 문자열을 해시해 시드로 쓰는 mulberry32이고, 정수 연산만 쓰므로 브라우저나 CPU가 달라도 결과가 같다. 더 중요한 점은 생성이 플레이와 독립적이라는 것이다. 생성기가 참조하는 값은 커서 높이와 난수뿐이고, 기체의 실제 속도나 프레임 간격은 쓰지 않는다. 60Hz 휴대폰에서 천천히 올라간 사람과 144Hz 모니터에서 빨리 올라간 사람이 같은 높이에서 같은 장애물을 만난다.

별도 같은 난수 흐름에서 뽑는다. 장애물마다 55% 확률로 빈 구간의 60% 높이에 별을 하나 놓고, 그중 60%는 터널 정중앙에 둔다. 가운데 별은 건너는 도중에만 지나갈 수 있으므로 "건너야 할 이유"가 하나 더 생긴다. 나머지는 벽에서 14유닛, 벽에 붙은 기체 중심에서 8유닛 떨어진 자리에 놓여 벽에 붙어 있기만 해도 획득 반경 11유닛 안에 들어온다.

200개 시드로 돌려 본 결과

이 글을 쓰면서 Node에서 world.js를 그대로 불러 생성기만 돌려 봤다. 시드 200개로 각각 4,000m까지 만들었다. 한 시드당 장애물은 평균 240개였고, 종류 비율은 가시 53.4%, 바 34.4%, 블록 12.2%로 나왔다. 처음 뽑는 비율(42/32/26)과 차이가 나는 것은 블록이 가시로 바뀌는 규칙 때문이다. 요구 방향이 바뀌는 연속 쌍 가운데 간격이 crossDist보다 좁은 경우는 하나도 없었다. 보장 규칙 자체는 의도대로 동작한다.

자동 플레이도 함께 돌렸다. README에 적힌 검증 기준은 "가장 가까운 장애물의 요구 방향으로만 뒤집는 단순 정책으로 120초 이상 생존"이다. 같은 정책에 "블록과 겹치면 건너지 않는다"는 조건만 더해 시드 100개로 돌린 결과 100개 모두 120초를 넘겼고, 그 시점의 거리 중앙값은 2,083m였다.

2,250m 이후에 생기는 일

문제는 숫자를 구간별로 나눠 보면서 드러났다. 요구 방향이 바뀌는 비율이 2,250m 전에는 40.5%였는데, 그 뒤로는 0.1%로 떨어졌다. 원인은 간단한 산수다. 간격 계수 1.7 − 0.6d가 여유 계수 1.25보다 작아지는 지점이 d = 0.75, 즉 2,250m다. 그 높이부터는 모든 간격이 crossDist보다 좁아지고, 생성기는 매번 직전과 같은 쪽을 요구하도록 장애물을 뒤집는다. 블록도 전부 가시로 바뀐다. 결과적으로 터널이 한쪽 벽만 쓰는 복도가 되고, 플레이어는 맞는 벽에 붙어 있기만 하면 된다.

같은 자동 플레이를 400초로 늘려 보니 50개 시드 모두 살아남았고 거리 중앙값은 10,479m였다. 그중 20개 실행을 따로 세어 보니 2,300m 이후 중력 반전은 한 번도 없었다. 그리고 최고 속도로 벽을 타면 2,250m에 닿는 데 약 120초가 걸린다. 검증 기준인 "120초 생존"이 정확히 이 경계 직전에서 끝나므로, 그 기준만으로는 이 구간을 볼 수 없었다. 실행 가능성은 지켰지만 그 대가로 후반의 선택지가 모두 사라졌다.

고칠 방향은 두 가지를 생각할 수 있다. 요구 방향을 바꿀 차례인데 간격이 모자라면 장애물을 뒤집는 대신 다음 장애물을 crossDist만큼 위로 밀어 올리는 방법이 하나다. 다른 하나는 간격 계수의 하한을 1.25보다 크게 두어 후반에도 두 방향이 섞이도록 하는 것이다. 어느 쪽이든 자동 플레이 검증 시간을 120초보다 길게 늘리고, 구간별 방향 전환 비율을 지표로 함께 보는 것이 먼저다.

1.0.2에서 고친 방법

1.0.2에서 첫 번째 방법을 택했다. 요구 방향이 바뀌는데 간격이 모자라면 방향을 되돌리지 않고, 장애물을 crossDist가 될 때까지 위로 밀어 올린다. 속도는 높이에 따라 오르므로 밀어 올린 높이의 속도로 crossDist를 다시 계산해 몇 번 반복한다. 간격 계수 하한을 올리는 방법은 같은 벽이 이어지는 구간까지 함께 느슨해지므로, 방향이 바뀌는 곳만 넓히는 쪽이 후반의 빽빽함을 덜 잃는다.

const side=rng()<.5?-1:1, req=type==='block'?0:-side;
if(req!==0&&req!==w.lastReq){
  for(let i=0;i<4&&w.genY-w.lastReqY<vt(w.genY/10)*CROSS_T*1.25-1e-9;i++)
    w.genY=w.lastReqY+vt(w.genY/10)*CROSS_T*1.25+0.5; }
...
if(req!==0) w.lastReq=req;
w.lastReqY=w.genY+o.h; // 블록도 기체를 벽에 묶는다

고치면서 하나를 더 찾았다. 예전 코드는 블록이 와도 lastReqY를 갱신하지 않았다. 그래서 방향이 바뀌는 두 장애물 사이 빈 구간에 블록이 끼면, 블록 앞뒤로 나뉜 각 구간이 건너기에 모자랄 수 있었다. 블록 끝도 기준점으로 세어 보면 시드 200개·4,000m에서 이런 배치가 17건 있었다. 이제는 블록도 벽을 강제하는 장애물로 보고 그 끝에서부터 간격을 잰다. 블록을 가시로 바꾸는 규칙은 필요 없어져 지웠다.

같은 시드 200개, 4,000m로 전후를 비교했다. 500m 구간별 방향 전환 비율은 2,500m 이후 0.0%에서 38~39%로 돌아왔고(2,000~2,500m는 13.0% → 40.9%), 블록 비율도 후반 0%에서 21% 안팎을 유지한다. 블록 앞뒤를 모두 기준으로 잰 간격 위반은 0건이다. 대신 방향이 바뀌는 곳마다 간격이 넓어져 후반 500m당 장애물 수는 29~31개에서 26~27개로 조금 줄었다.

자동 플레이는 0.05초 단위로 반전 시점을 탐색하는 선행 탐색 봇으로 다시 돌렸다. 정상적으로 반전하는 봇은 전후 모두 200개 시드 전부 4,000m까지 살아남았다. 2,500m까지는 정상 플레이하다가 그 뒤로 반전을 멈추는 "한쪽 벽 버티기" 봇은 수정 전 200개 모두 4,000m에 닿았지만, 수정 후에는 200개 모두 죽었다. 죽은 거리 중앙값은 2,542m, 가장 멀리 간 경우도 2,763m였다. 검증 기준도 "120초 생존"에서 구간별 전환 비율과 버티기 봇 사망까지 함께 보는 것으로 넓혔다.

정리

FLIP의 생성기는 장애물을 요구 방향으로 단순화하고, 방향이 바뀌는 곳에서만 건너기 거리의 1.25배 간격을 검사한다. 이 규칙만으로 피할 수 없는 배치는 시뮬레이션 4만 개가 넘는 쌍에서 한 번도 나오지 않았다. 하지만 간격이 시간상 점점 좁아지는 곡선과 만나면서, 1.0.1까지는 2,250m 이후 보장 규칙이 모든 장애물을 같은 쪽으로 몰아 버렸다. 1.0.2는 방향을 되돌리는 대신 장애물을 밀어 올려, 후반에도 전환 비율 약 38%와 간격 위반 0건을 함께 지킨다. 깰 수 있는지만 확인하는 검증은 충분하지 않고, 선택이 남아 있는지도 함께 재야 한다는 것을 숫자로 확인했다.