DEV NOTE · JJ

검증된 조각만 이어 붙이는 레벨 생성기: JJ DAILY·RANDOM

JJ의 DAILY와 RANDOM 코스는 시드 문자열 하나로 만들어집니다. 협동 물리 퍼즐에서 자동 생성의 어려움은 "만들 수 있느냐"보다 "풀 수 있느냐"에 있습니다. 결론부터 말하면 설계 문서에 적었던 상태 탐색 솔버는 만들지 않았고, 미리 풀어 둔 챔버 템플릿을 이어 붙인 뒤 그 풀이를 실제 물리로 다시 재생해 통과한 코스만 내보내는 방식을 택했습니다. 200개 시드 회귀 테스트에서 전부 통과합니다.

처음 계획은 상태 탐색이었다

설계 문서의 원래 계획은 두 단계였습니다. 1단은 챔버를 그래프로 보고 두 코어에 "어디로 이동, 앵커, 당기기 몇 초, 점프" 같은 매크로 동작을 배정하는 계획 탐색입니다. 상태는 두 코어가 선 발판, 0.5칸 단위로 양자화한 줄 길이, 기둥에 감긴 지점의 집합으로 정의했습니다. 2단은 찾아낸 계획을 실제 시뮬레이션으로 재생해 확인하는 단계입니다.

문제는 1단이었습니다. 두 코어와 줄이 얽힌 상태 공간은 조금만 장치가 늘어나도 폭발합니다. 설계 문서의 리스크 항목에도 "생성기가 만드는 챔버 문법을 솔버가 풀 수 있는 범위로 의도적으로 좁힌다"고 적어 두었는데, 범위를 좁히다 보니 결국 검색할 필요가 없을 만큼 좁아졌습니다. 그래서 1단을 탐색 대신 "이미 풀이가 붙어 있는 템플릿"으로 대체했습니다. 생성 다양성은 줄어들지만, 매일 모든 사람이 같은 코스를 받는 DAILY에서 풀 수 없는 코스가 나오는 것은 용납할 수 없었습니다.

챔버 템플릿 9종

템플릿은 높이 15줄의 ASCII 지형 조각과, 그 조각을 두 코어가 통과하는 저자 플랜으로 이뤄집니다. 저자 플랜은 "목표 지점까지 이동", "달려서 점프", "줄을 장력 0.82까지 늘리기", "앵커 켜기", "당기기 0.45초 유지" 같은 매크로 단계를 나열한 것입니다. 수제 스테이지 24개의 회귀 테스트와 타이틀 화면의 데모 플레이도 같은 매크로 해석기를 씁니다.

템플릿요구 동작난이도 범위 d
gap점프 (틈 2~3칸, d 0.4 초과면 가시)0 ~ 1
step1칸 턱 점프0 ~ 0.7
sling새총, 도르래 (틈 5칸)0.3 ~ 1
pit앵커, 당기기 (깊이 2칸 구덩이)0 ~ 0.8
gate절단0.25 ~ 1
pads동시 압력판0 ~ 1
membrane동시 점프, 새총0.5 ~ 1
updraft바람, 절단0.45 ~ 1
crystal운반0.55 ~ 1

코스는 시작부 6칸, 챔버 n개, 결합 장치가 있는 종결부 6칸으로 구성되고, 챔버 사이에는 체크포인트가 들어간 3칸 연결부가 끼어듭니다. 챔버 수는 3 + ⌊d × 3⌋입니다. 같은 템플릿이 연속으로 나오지 않고, 크리스탈은 코스당 한 번, 압력판 묶음은 최대 세 번까지만 씁니다. 템플릿마다 플레이어용 힌트 문장도 붙어 있어서 "틈 5칸, 새총. 앵커는 가장자리에 바짝" 같은 안내가 해당 챔버 앞에 표시됩니다.

JJ 스테이지 선택 화면에 표시된 DAILY 카드와 RANDOM 난이도 선택
0.3.0에서 추가된 DAILY·RANDOM 카드. 난이도와 시드를 고르면 그 자리에서 코스를 만든다.

풀이를 물리로 다시 푸는 2단 검증

템플릿 하나하나는 검증되어 있어도, 이어 붙이면 앞 챔버를 끝낸 자세나 위치가 달라져 다음 챔버 풀이가 어긋날 수 있습니다. 그래서 이어 붙인 전체 플랜을 실제 시뮬레이션으로 처음부터 끝까지 재생합니다.

// jj/js/gen.js 발췌: 안전 여유(margin)를 켠 세계에서 플랜을 재생
const w=W.createWorld(level,{margin:true}); const s=MACRO.make(level.plan);
while(!w.cleared && n<lim && !s.failed){ const [a,b]=s.next(w); W.step(w,a,b); n++; }

검증할 때는 게임보다 조금 어렵게 만듭니다. 가시 판정 영역을 각 면 0.2칸씩 넓히고, 게이트와 문이 열려 있는 시간을 0.3초 줄입니다. 저자 플랜은 매번 완벽한 타이밍으로 움직이지만 사람은 그렇지 않으니, 여유를 두고 통과한 코스라야 사람에게도 공정합니다. 설계 초안은 심연 폭도 0.5칸 넓혀서 풀 계획이었는데, 바닥 틈을 넓히면 템플릿 기하가 깨져서 가시만 키우는 쪽으로 바꿨습니다.

재생이 실패하면 어느 챔버에서 막혔는지 플랜 단계로 역추적해, 그 자리의 템플릿만 다른 것으로 바꿔 다시 검증합니다. 이렇게 세 번까지 시도하고, 그래도 안 되면 시드에 접미사를 붙여 코스 전체를 새로 뽑습니다(최대 네 번). 통과한 플랜의 클리어 시간에 1.6을 곱한 값이 그 코스의 파 타임이 됩니다.

숫자로 본 결과

회귀 테스트 node jj/tests/gen.mjs 200은 시드 200개를 난이도 0~1에 고르게 섞어 생성하고 통계를 냅니다. 이 글을 쓰면서 다시 돌린 결과는 다음과 같습니다.

다시 뽑힌 경우는 모두 21번이었습니다. 처음 집계에서는 pads 2회, sling 1회, gate 1회로 나오고 나머지 17회는 템플릿을 알 수 없다는 뜻의 "?"로 남았습니다. 원인은 집계 스크립트에 있었습니다. 검증 결과가 돌려주는 실패 위치는 챔버 순번이 아니라 코스를 이루는 조각 목록(시작부, 연결부, 챔버, 종결부)의 인덱스입니다. k번째 챔버는 조각 인덱스 2k+2에 있는데, 스크립트는 이 값에서 1만 빼서 챔버 목록을 찾았습니다. 그래서 대부분은 목록 범위를 벗어나 "?"가 됐고, 범위 안에 들어온 4건도 엉뚱한 템플릿 이름으로 집계됐습니다. 인덱스 변환을 고친 뒤(2026-10-13) 같은 200개 시드를 다시 돌리니 21건 모두 updraft(상승기류 + 절단)로 분류됐고, 실패 기록에 남는 마지막 플랜 라벨도 21건 모두 updraft였습니다. 앞 챔버를 끝낸 위치나 줄 상태에 따라 상승기류를 타는 타이밍이 어긋나는 템플릿이라는 뜻입니다. 재뽑기로 전부 통과하므로 코스가 막히는 일은 없지만, 첫 시도 통과율을 더 올리려면 이 템플릿의 진입 플랜부터 손보면 됩니다. 이 스크립트는 CI에도 들어가 있어(시드 60개), 물리 수치를 하나라도 바꾸면 수제 스테이지 24개와 함께 생성기 통과 여부가 자동으로 검사됩니다.

DAILY와 RANDOM의 시드

난수는 정수 연산만 쓰는 해시와 mulberry32 생성기로 만듭니다. 플랫폼마다 부동소수점 결과가 미세하게 달라질 수 있는 함수는 생성 과정에서 쓰지 않습니다. DAILY의 시드는 daily-YYYY-MM-DD 형식의 날짜 문자열이고 난이도는 0.5로 고정이라 챔버가 네 개입니다. 설계 초안에서는 챔버 5개였지만, 챔버 수를 난이도와 연동하면서 DAILY는 파 타임 20~50초 수준의 4챔버로 정리했습니다.

RANDOM은 난이도 1~5를 각각 d 0.05, 0.3, 0.5, 0.75, 0.95로 대응시키고, 다섯 글자 시드를 새로 뽑거나 직접 입력할 수 있습니다. 시드와 난이도는 ?seed=…&d=… 형태의 링크로 공유되고, 링크를 연 사람의 브라우저에서 같은 코스가 다시 만들어집니다. 생성이 결정적이라는 성질은 DAILY 리더보드의 서버 검증에서도 그대로 쓰입니다. 서버는 코스를 저장하지 않고, 같은 날짜 시드로 같은 코스를 직접 생성해 제출된 리플레이를 재생합니다.

정리

"매크로 플래너로 상태를 탐색한다"는 원래 계획은 템플릿 지식으로 대체되었고, 그 대가로 코스의 다양성은 템플릿 9종의 조합 범위에 묶였습니다. 대신 하루에 하나 나오는 공용 코스가 풀리지 않는 사고는 구조적으로 막았습니다. 다양성이 부족하다는 신호가 플레이 데이터에서 보이면 그때 상태 탐색을 다시 검토할 생각이고, 그 전까지는 템플릿을 늘리는 쪽이 더 싼 선택입니다.