AX 노트 · 방법

화살표 퍼즐 1,100판을 만든 역구성 생성기와 G2 게이트

이동 규칙을 문서로 먼저 못 박으면, 거꾸로 쌓아 만든 판은 따로 풀어 보지 않아도 모두 풀립니다. 재미는 게이트가 따로 검사합니다.

작성 SugarMount 게임 개발 읽는 시간 13분 최종 업데이트

화살표 매듭풀기의 1,100판(본편 1~1000판과 무한 모드 대체 1001~1100판)은 튜토리얼 1~3판까지 모두 생성기가 만들었습니다. 튜토리얼도 코드에 적은 것은 크기와 목표 범위뿐입니다. 2026년 10월 3일 18시 32분 커밋(e3c7781)에서 이 팩이 팩 단위 게이트 G2를 처음 통과했고, 판마다 거는 구조 게이트 6종도 1,100판 모두 통과합니다. 그 이틀 전 생성기 v2(6249b11)는 생성기 벤치의 가장 어려운 구간에서 처음부터 빼낼 수 있는 화살표 비율(F0)을 0.50에서 0.11로 내렸습니다.

근거: 게임 저장소의 git 기록(ecedf0b, 6249b11, e3c7781, 9de89eb), ADR-0001, 레벨 보고서 report-v2·report-v3(.md, .csv), 착수 단계 교차검증 문서, 2026년 10월 1일·3일 작업 세션 기록. 보고서 숫자는 2026-10-28 HEAD에서 다시 읽었습니다.

목차 (8)
  1. 무작위로 뿌린 판은 10×10에서 전부 막혔습니다
  2. 이동 규칙을 ADR로 먼저 고정했습니다
  3. v1 판은 화살표 절반을 처음부터 빼낼 수 있었습니다
  4. 300판에서 나온 결함이 G2 항목이 됐습니다
  5. G2가 놓친 마스크 반복과 판 단위 실패
  6. 단조성 테스트부터 바이트 비교까지 7단계
  7. F0와 깊이는 실제 플레이 기록과 대조하지 않았습니다
  8. 자주 묻는 질문

무작위로 뿌린 판은 10×10에서 전부 막혔습니다

화살표 매듭풀기는 판 위의 화살표를 하나씩 눌러 빼는 퍼즐입니다. 화살표는 머리 방향으로 미끄러져 나가는데, 그 길(코드 이름은 escape ray, 아래에서는 광선) 위에 다른 화살표가 있으면 막혀서 되돌아오고 하트가 하나 줄어듭니다. 2026년 9월 30일 착수 보고서는 판을 만든 뒤 "실제 제거 시뮬레이션으로 완주 여부를 검증한다"고 적었습니다.

같은 날 돌린 교차검증 시뮬레이션에서 화살표를 무작위로 놓은 판이 풀리지 않는 비율은 8×8에 10개일 때 64%, 16개일 때 92%, 10×10에 25개부터는 100%였습니다. 출시본의 861~1000판은 24×44 판에 화살표 수 중앙값이 73개라, 버리고 다시 뽑는 루프로는 닿지 못하는 크기입니다.

대안은 거꾸로 쌓는 역구성입니다. 빈 판에 화살표를 하나씩 놓되, k번째로 놓는 화살표는 마지막에서 k번째로 빠질 화살표가 되게 합니다. 놓는 순간 그 화살표의 광선이 이미 놓인 화살표와 겹치지 않을 때만 받아들이면, 놓은 순서를 뒤집은 것이 곧 풀이입니다. 나중에 놓는 화살표가 먼저 놓인 화살표의 광선을 막는 것은 허용되고, 그 막힘이 난이도가 됩니다. 같은 시뮬레이션에서 이렇게 만든 판 200개는 모두 풀렸습니다.

비교 그림: 무작위 배치 후 검증은 10×10에 화살표 25개부터 판 100%가 풀리지 않고, 역구성은 놓을 때마다 광선이 비었는지만 확인해 1,100판 모두 풀립니다.
판을 만든 뒤 풀어 보는 방식은 크기가 커지면 멈추고, 거꾸로 쌓는 방식은 만드는 순서가 곧 풀이입니다.

이동 규칙을 ADR로 먼저 고정했습니다

역구성이 성립하려면 "한 번 빼낼 수 있게 된 화살표는 끝까지 빼낼 수 있다"는 단조성이 필요합니다. 그런데 착수 보고서는 화살표가 어떻게 움직이는지 정하지 않았습니다. 굽은 화살표가 뱀처럼 머리를 따라 빠지는지, 몸통 전체가 평행으로 밀려 나가는지에 따라 막는 칸이 달라집니다. 교차검증에서 머리 기준으로 만든 판 400개를 평행 이동 기준으로 다시 풀어 보니 52개(13%)만 풀렸습니다.

9월 30일 첫 커밋(ecedf0b)과 함께 채택한 ADR-0001(설계 결정 기록)이 이 빈칸을 메웠습니다. ADR은 이 광선 계산 하나를 생성기, 제거 판정, 힌트, 애니메이션이 함께 쓰도록 정했습니다(판정·애니메이션은 Board.escapeRay, 생성기는 그 안의 같은 계산 size.ray를 부릅니다). 막힌 탭은 하트 1개를 줄일 뿐 판 상태를 바꾸지 않습니다. 이 셋이 모이면 제거는 단조적이고, 판이 풀린다는 것은 막힘 그래프(화살표 i의 광선 위에 j가 있으면 i가 j를 기다리는 관계)에 사이클이 없다는 것과 같아집니다.

그래서 풀림 판정에는 탐색 없이 벗겨 내기(peel) 하나면 됩니다. 지금 빼낼 수 있는 화살표를 한 번에 다 빼는 일을 반복하고, 몇 번 만에 끝나는지가 깊이(layers)입니다. 출시 게이트의 풀림 검사는 peel 성공과 별도의 사이클 탐색(DFS)을 둘 다 요구합니다. 단조성 자체는 속성 테스트로 확인합니다. 생성한 판 300개와 무작위 순서로 끝까지 빼는 시행 1,000회가 커밋 전에 돌리는 테스트에 들어 있습니다.

퍼즐 생성기를 짜기 전에 이동 규칙부터 ADR로 고정하십시오. 두 모델이 함께 푼 판은 400개 중 52개였습니다.

v1 판은 화살표 절반을 처음부터 빼낼 수 있었습니다

ecedf0b의 v1 생성기(Claude와 함께 쓴 코드)는 풀리는 300판을 만들었지만 깊이가 낮았습니다. v1 보고서에서 가장 어려운 expert 구간도 F0가 0.494, 깊이 5.6이었습니다. 판을 열자마자 빼낼 수 있는 화살표가 절반이었고, 다섯 번 남짓 벗겨 내면 판이 끝났습니다.

10월 1일 22시 32분, 메인 세션의 에이전트는 생성기 연구를 하위 에이전트에게 맡기며 시도할 목록을 적었습니다. 그중 둘이 광선을 많이 막는 후보에 점수를 주는 것과, 마지막에 자유 화살표를 막힌 화살표로 바꾸는 패스입니다. 광선을 덮는 점수는 v1에 이미 있었고, 하위 에이전트는 자유 화살표의 광선을 막는 점수(freeWeight)를 더했습니다. hard 구간(11×13) 60판으로 재 보니 그 점수만 쓴 판의 F0는 0.51로, v1 그대로(0.48)와 다르지 않았습니다. 빌드 후반에는 그런 자리에 놓을 후보가 거의 없고, 맨 마지막 화살표는 어차피 자유입니다. 자유 화살표를 둘 이상 막을 때만 하나 더 놓는 마지막 패스(blockerPlacements)도 효과가 0이었습니다.

F0를 0.5에 묶어 둔 것은 빈 광선이었습니다. 머리가 테두리에 붙어 밖을 향하는 화살표는 광선 칸이 0개라 무엇으로도 막을 수 없습니다. 광선이 지나는 사용 가능한 칸이 하나도 없는 화살표를 거부하는 minRay=1 하나로 F0가 0.38까지 내려갔습니다. 꼬리 성장을 더하자 F0는 0.30까지 내려갔습니다. 머리는 그대로 두고 꼬리만 이웃 빈칸으로 늘리면 광선은 변하지 않고 막힘 관계만 늘어나므로, F0는 내려가기만 하고 깊이는 올라가기만 합니다. 늘릴 때마다 DFS로 사이클이 생기는지 확인합니다. 처음에는 자유 화살표의 광선 쪽으로만 늘리고, 다음에는 아무 빈칸으로나 늘려 밀도를 높입니다(빈칸 성장). 그래도 남은 구멍에는 사이클이 생기지 않을 때만 새 화살표를 넣습니다.

막대 그림: hard 구간 60판의 F0는 v1 0.48, minRay 0.38, freeWeight 단독 0.51, 꼬리 성장 0.30, 빈칸 성장 0.27, 구멍 채우기 0.16입니다.
자유 화살표를 막는 점수만으로는 F0가 내려가지 않았고(0.48→0.51), 빈 광선 거부와 꼬리 성장·구멍 채우기가 0.16까지 내렸습니다.

연구 보고는 17분 뒤에 돌아왔고, 이 조합이 22시 53분 커밋 6249b11의 v2입니다(벤치 expert 구간 깊이 5.8→14.6, 밀도 0.74→0.97). 성장 없이 처음부터 긴 무작위 경로로 화살표를 그리는 방법은 밀도 0.65, F0 0.44여서 쓰지 않았습니다.

300판에서 나온 결함이 G2 항목이 됐습니다

10월 3일 15시 48분 운영자가 "300판 끝나면 앱이 끝나는 거야?"라고 묻고 7분 뒤 1,000판을 요구하자, 에이전트가 v2의 300판 전체를 다시 분석했습니다. 판을 한 화면에 다 넣느라 276~300판의 평균 칸이 13.6dp, 가장 작은 판은 11.7dp까지 줄었고, 150판 이후 깊이는 16에서 21 사이에 머문 채 화살표 수만 늘었습니다. 어려운 판 35개 중 4개는 화살표 수가 직전 판의 두 배를 넘었습니다. 보고서 CSV로 다시 세어 보니 293판은 직전 판 34개에서 94개(2.8배), 243판은 30개에서 76개였습니다. 일반 판인 300판(109개)도 299판(77개)보다 40% 많았습니다.

에이전트는 '판 크기 상한'과 '경쟁작처럼 큰 판 + 확대·스크롤' 두 안을 묻고 상한을 추천했습니다. 운영자가 고른 것은 큰 판 쪽이고, 구간도 운영자가 냈습니다. 1~200판은 같은 크기 상한을 지키고 201~500, 501~700, 701~1000판으로 갈수록 판을 키우는 안입니다. 에이전트는 칸 크기는 지키고 판을 화면보다 크게 만들어 스크롤하는 쪽으로 정리했고, 1부 칸 27dp 이상이라는 숫자는 에이전트가 붙였습니다.

그 뒤 사양서는 이 결함들을 게이트로 옮겼습니다. 표에서 ramp는 난이도 곡선을 따라가는 일반 판, spike는 주기마다 한 번 튀는 어려운 판, breather는 쉬어 가는 판이고, T는 화살표 수와 F0로 계산한 예상 풀이 시간입니다. 결과는 지금 report-v3.md 기준입니다.

300판에서 본 결함·바뀐 구조G2 항목결과(1,100판)
칸 평균 13.6dp, 최소 11.7dp1부 칸 27dp 이상(폭 360dp 기준)최소 27.3dp
어려운 판 하나가 직전 판의 2.8배spike T가 직전 ramp 중앙값의 1.10~1.25배(중앙값), p90 1.30배 이하spike 101판, 중앙값 1.12, p90 1.18
50판 정체 뒤 화살표 수 40% 급등(201~250판 → 251판대)spike·실루엣이 아닌 판은 직전 일반 판 화살표 수의 1.15배 이하, breather T는 직전 ramp 중앙값의 0.60~0.90배이웃 위반 0건, breather 112판 0.61~0.88
크기로만 어려워짐941~1000판 깊이 중앙값이 101~200판의 1.6배 이상29 대 14, 2.07배
판을 키우는 4부 구조(운영자가 정한 구간)새 부 첫 10판 화살표 수 1.10배 이하, 깊이 0.85배 이하201판 1.05배·0.75배 등 4곳 모두 통과

판마다 거는 구조 게이트 6종은 1,100판 모두 통과했습니다. L/A(화살표 수 대비 깊이, ramp 0.60 이하), 단일 웨이브 연속(한 번에 화살표 하나만 빠지는 단계가 4번 넘게 이어지지 않기), F0, 무작위 탭 승률, 판 크기 상한, 풀림입니다. 무작위 탭 승률은 하트 3개로 아무 화살표나 누르는 시행을 판마다 100번 돌린 값입니다. report-v3.csv에서 6판 이후 1,095판을 세어 보면 1,071판이 0이고, 2부부터는 전부 0입니다.

난수는 Kotlin Random(seed)(교차검증이 버전 간 불안정을 지적) 대신 자체 SplitMix64를 쓰고, e3c7781은 두 번 빌드한 팩이 바이트 단위로 같다고 기록했습니다.

G2가 놓친 마스크 반복과 판 단위 실패

G2를 통과하고 30분 뒤인 19시 2분, 커밋 9de89eb가 G2 자체를 고쳤습니다. 무한 모드의 실루엣이 140판 주기로 같은 마스크를 몇 판 간격으로 다시 쓰고 있었습니다(성 1000·1005판, 물고기 985·1015판, 트로피 995·1025판). G2가 마스크를 (이름, 좌우 반전) 쌍으로 묶어 세서 반전된 같은 그림을 다른 그림으로 봤습니다.

128판은 목표 범위를 못 맞춰 가장 가까운 후보를 썼고, 무한 대체 구간 1060판은 판 단위 톱니(saw) 검사에 걸린 채 실렸습니다. G2의 톱니 항목은 spike 101판의 중앙값과 p90만 봅니다. 무작위 탭 승률 상한 0.5도 느슨해서, 7판은 정확히 0.50입니다.

G2는 JVM에서 팩 파일을 읽으므로 패키징 문제(aapt가 .gz를 떼어 낸 실행 직후 크래시)는 볼 수 없었고, 경위는 '에이전트 수백 개 써도 돼'의 실제에 적어 두었습니다. 파이프라인은 G2가 실패해도 팩을 다 쓰고 표준 오류에 "G2 FAILS" 한 줄을 출력할 뿐입니다. G2 실패는 커밋된 팩에서 통계를 다시 계산해 G2를 돌리고 커밋된 report-v3.csv와 비교하는 CurveGateTest가 잡고, 커밋 전에 이 테스트를 통과시키는 것이 저장소 규칙입니다.

ADR-0001은 회전 화살표, 충돌하면 멈추는 화살표, 열쇠와 자물쇠, 움직이는 타일처럼 단조성을 깨는 기믹을 넣으려면 새 ADR과 탐색 기반 검증기(BFS나 SAT)가 먼저 있어야 한다고 적었습니다. T와 무작위 탭 승률은 모델 값이라, 사람이 느끼는 시간은 목표 시간을 30% 줄인 시뮬레이션 기록에서 따로 조정했습니다.

단조성 테스트부터 바이트 비교까지 7단계

"빼거나 지우면 길이 열리기만 하는" 규칙이면 화살표 퍼즐이 아니어도 이 순서를 따를 수 있습니다.

  1. 이동·제거 규칙을 ADR로 쓰고, 판정·힌트·애니메이션이 같은 광선 계산을 쓰게 합니다.
  2. "제거 가능한 것은 계속 제거 가능한가"를 무작위 순서 시행 1,000회쯤의 속성 테스트로 확인합니다.
  3. 단조적이면 역구성으로 만듭니다. k번째 조각은 광선이 이미 놓인 조각과 겹치지 않을 때만 받고, 광선 칸이 0인 조각은 거부합니다.
  4. 난이도는 머리를 고정한 꼬리 성장으로 올리고, 늘릴 때마다 DFS로 사이클을 확인합니다.
  5. 판마다 F0, 깊이, L/A, 단일 웨이브 연속, 무작위 탭 승률을 CSV로 기록합니다.
  6. 팩 게이트는 판에서 지표를 다시 계산해 커밋된 보고서와 비교하는 테스트로 겁니다.
  7. 시드는 레벨 번호와 생성기 버전으로 만들고, 출력이 바뀌면 버전을 올립니다. 두 번 빌드해 바이트를 비교하고, 패키징된 앱을 기기에서 한 번 엽니다.

3·4단계와 깊이 계산을 의사코드로 줄이면 이렇습니다.

place(a):                       # 역구성, 놓는 순서의 역순이 풀이
  if usable(ray(a)) == 0: reject  # 빈 광선은 영원히 막을 수 없음
  if ray(a) hits occupied: reject   # 이미 놓인 화살표와 겹치면 거부
  accept(a)

growTail(a, cell):              # 머리 고정, 광선 불변
  waiting = arrows whose ray crosses cell
  if cell not empty or not next to a's tail end: skip
  if a at max length or cell on ray(a): skip   # 자기 광선을 막지 않음
  if reaches(a, waiting): skip    # DFS: 사이클이 생기면 포기
  extend a's tail into cell

depth(board):                   # peel
  layers = 0
  while board not empty:
    if nothing removable: return unsolvable
    remove every removable arrow; layers += 1
  return layers

이 저장소에서는 ./gradlew :arrow-engine:test :level-pipeline:test가 2단계 속성 테스트, 6단계 G2 테스트, 7단계 결정성 테스트를 함께 돌립니다.

F0와 깊이는 실제 플레이 기록과 대조하지 않았습니다

이 글의 F0·깊이 숫자는 생성기와 보고서에서 나온 값이고, 실제 플레이어가 몇 판에서 막히는지와는 대조하지 않았습니다. 시뮬레이션 플레이어 기준으로 spike 판이 같은 구간 ramp보다 클리어율이 5점 이상 낮아야 한다는 검사는 재조정 커밋 ab3f78c에서 실패했습니다. 같은 날 22시 28분 사양서 보완 커밋 91d3c8c가 이 검사를 풀이 시간 비율 1.10~1.25배와 "클리어율이 ramp보다 3점 넘게 높지 않기"로 바꿨습니다. 시뮬레이션 플레이어의 클리어율도 모델 값이라, 실제 플레이와 맞는지는 아직 모릅니다.

자주 묻는 질문

퍼즐 레벨을 자동 생성하면 모든 판이 풀린다고 보장할 수 있나요?

규칙이 단조적이면 역구성으로 보장할 수 있습니다. 화살표 매듭풀기는 놓은 순서를 뒤집으면 풀이가 되고, 출시 게이트가 peel과 사이클 탐색(DFS)으로 1,100판을 다시 확인합니다.

F0는 무엇이고 왜 낮춰야 하나요?

F0는 판을 열자마자 빼낼 수 있는 화살표의 비율입니다. v1 expert 구간은 0.494여서, 판을 열자마자 화살표 절반을 빼낼 수 있었습니다. 출시본은 판 역할(ramp·spike·breather)별 목표 범위를 두고, report-v3.csv 기준 4부(701~1000판) 중앙값이 0.11입니다.

이 글은 SugarMount가 직접 측정한 값으로 쓰고, 초안 작성에 AI 코딩 에이전트를 썼습니다.

게임 개발 글 전체 보기 · AX 노트 전체 보기