이 글은 Today I Learn 시리즈의 52번째 기록입니다. (총 125개)

같은 규칙이 두 곳에 있을 때

규칙을 두 번 구현하면 반드시 갈라진다

이 게임의 규칙은 두 군데에 있다. 게임 본체와 레벨을 자동으로 만드는 도구다.

나뉜 데는 이유가 있다. 게임은 Flutter로 만들어 폰에서 돌아가야 하니 Dart다. 레벨 생성기는 폰에 들어가지 않는다 — 개발 중에 컴퓨터에서 돌려 레벨 파일 500개를 만들어 두고, 앱은 그 결과만 읽는다.

레벨을 앱에서 그때그때 만들 이유가 없다. 만들려면 봇을 수만 번 돌려야 하는데 그 값을 사용자 폰이 치를 이유가 없고, 판이 매번 달라야 할 게임도 아니다. 미리 만들어 두면 품질을 확인하고 내보낼 수 있다. 그쪽은 시뮬레이션과 이미지 처리라 그런 도구가 잘 갖춰진 Python이 맞았다.

문제는 레벨 생성기도 “이 판을 깰 수 있는가”를 판정해야 한다는 것이다. 그러려면 게임과 같은 규칙을 알아야 한다. 결국 같은 규칙을 두 번 짠 셈이고, 한쪽만 고치면 게임과 생성기가 서로 다르게 동작한다.

사람이 눈으로 대조하는 것으로는 못 막는다고 봤다. 그래서 fixture — 입력과 기대 출력을 미리 적어 둔 테스트 데이터 — 를 하나 두고 양쪽이 각자 돌려 맞춰 보게 하기로 했다. 한쪽이 어긋나면 그쪽 테스트가 깨진다.

  • 같은 규칙이 두 곳에 있으면 갈라지는 것은 시간 문제다. 사람이 아니라 기계가 대조하게 만든다
  • 규칙을 바꿀 때 순서를 고정했다. 스펙 문서 → 양쪽 코드 → 공통 fixture. 문서가 먼저인 이유는 두 구현이 참고할 원본이 하나여야 하기 때문이다

통과해도 의미가 없는 테스트가 있다

fixture를 만들 때 함정이 하나 있었다. 벽 규칙을 검사하려고 만든 케이스인데, 벽을 아예 무시하고 짜도 우연히 같은 결과가 나오는 배치였다. 그러면 벽 규칙이 망가져도 테스트가 통과한다.

  • 테스트는 틀렸을 때 틀렸다고 알려 줘야 값어치가 있다. “통과했다”는 것만으로는 아무것도 보장하지 않는다
  • 케이스를 만들 때 “이게 깨지면 이 테스트가 정말 빨개지나”를 한 번 물어본다

난이도를 재는 법

재는 도구의 수준이 지표를 만든다

레벨 난이도를 내가 직접 매길 수는 없다. 500판을 다 해 볼 수도 없고, 해 본다 해도 그날 컨디션에 따라 기준이 흔들린다. 같은 판을 같은 방식으로 여러 번 재는 일은 사람이 못 한다.

그래서 봇에게 여러 번 풀게 하고 깬 비율로 난이도를 정하기로 했다. 그런데 봇을 어떻게 만드느냐에 따라 결과가 통째로 달라졌다.

늘 최선을 고르는 봇은 거의 모든 레벨을 100% 깬다. 쉬운 판과 어려운 판이 똑같이 100%라 구분이 안 된다. 반대로 아무렇게나 두는 봇은 어려운 판을 전부 0%로 만든다. 이것도 구분이 안 된다.

양쪽 끝은 다 쓸모가 없었다. 필요한 건 평균적인 사람 — 대체로 잘하다 가끔 실수하는 봇이다. 그래서 ε-greedy(엡실론 그리디 — 대부분은 최선을 고르되 작은 확률로 아무거나 고르는 방식)를 쓰고, 실수 확률을 30~35%로 뒀다.

  • 측정 도구가 대상보다 너무 좋거나 너무 나쁘면 지표가 평평해진다. 재려는 범위 안에서 갈리는 도구를 골라야 한다
  • 사람을 흉내 내는 지표는 “완벽한 사람”이 아니라 “보통 사람”을 기준으로 삼는다

어려워지지 않는 도안이 있다

측정하다 알게 된 것 둘. 클리어율을 가장 크게 좌우한 것은 벽 배치가 아니라 바스켓을 몇 조각으로 쪼갰는가였다. 벨트 자리가 붐빌수록 어려워진다.

그리고 색이 두 개뿐인 도안은 어떤 배치로도 잘 안 어려워졌다. 색이 층층이 교차하지 않아서 “부을 데가 없어 기다리는” 상황이 안 생긴다.

  • 난이도를 만드는 손잡이가 여럿일 때, 어느 것이 실제로 크게 움직이는지는 재봐야 안다. 짐작한 것(벽)과 달랐다
  • 소재 자체에 한계가 있으면 장치로 못 넘는다. 어려운 자리에 쓸 도안은 처음부터 골라 둬야 한다

비싼 측정 앞에 싼 필터를 둔다

클리어율을 재려면 봇을 수십 번 돌려야 해서 레벨 하나에 분 단위가 걸린다. 후보를 전부 이렇게 재면 생성이 끝나지 않는다.

그래서 시뮬레이션 없이 계산되는 값을 먼저 봤다. 보드가 빌 때까지 “지금 꺼낼 수 있는 것”을 한 겹씩 걷어내며 몇 번 반복해야 하는지 세는 것이다. 깊이 묻힌 배치일수록 이 숫자가 크다. 이걸로 먼저 거르고, 통과한 후보만 비싼 측정으로 넘기기로 했다.

해상도도 깎기로 했다. 난이도를 만드는 건 퍼즐 구조지 픽셀 개수가 아니다. 도안을 4분의 1로 줄인 축소판에서 재도 구간을 나누기에는 충분했고, 정확한 값이 필요할 때만 원래 크기로 한 번 더 봤다.

  • 비싼 판정 앞에 싼 판정을 두면 전체가 몇 배 빨라진다. 싼 쪽이 완벽할 필요는 없고 명백한 탈락만 걸러 주면 된다
  • 목적이 정확한 수치가 아니라 구간 분류라면 정밀도를 깎아도 된다. 무엇을 위해 재는지가 정밀도를 정한다

만드는 쪽과 검증하는 쪽을 나눈다

벽을 무작위로 깔다 보면 사방이 막혀 출구까지 길이 없는 칸이 생긴다. 거기 바스켓이 들어가면 영영 못 꺼내는, 깰 수 없는 레벨이 된다.

만들 때 조심하는 쪽이 아니라, 깔고 나서 확인하고 안 되면 버리는 쪽으로 갔다. 출구에서부터 번져 나가 모든 칸에 닿는지 보고, 하나라도 못 닿으면 그 배치는 버리고 다시 만든다.

  • 생성과 검증을 나누면 생성 쪽이 자유로워진다. 만드는 쪽에서 규칙을 다 지키려 하면 패턴이 단조로워진다
  • 버리고 다시 만드는 비용이 싸다면 그게 제일 단순한 답이다

총량이 맞아야 성립하는 게임에서는 반올림 하나가 판을 망친다

한 색의 모래를 여러 바스켓에 비율대로 나눠 담아야 한다. 100알을 셋으로 나누며 각자 반올림하면 99가 되어 한 알이 사라진다.

이 게임은 색별 총량이 캔버스와 정확히 맞아야 클리어된다. 한 알만 어긋나도 그 레벨은 깰 수 없다. 그래서 정수 부분을 먼저 나눠 주고 남은 것을 소수 부분이 큰 순서로 하나씩 얹는 방식(최대잉여법)으로 가기로 했다. 어떻게 나누든 합은 그대로다.

  • 총량이 성립 조건인 시스템에서는 “거의 맞음”이 곧 실패다. 나누는 자리마다 합이 보존되는지 확인한다

조용히 죽는 것들

조건이 여러 층에 흩어지면 기능이 통째로 안 될 수 있다

되돌리기 버튼이 아무리 눌러도 반응하지 않았다. 오류도, 로그도 없었다.

조건이 두 군데 있었다. 화면 쪽은 “벨트가 비어 있어야 버튼이 켜진다”, 엔진 쪽은 “되돌릴 바스켓이 벨트에 살아 있어야 실행한다”. 각각 따로 보면 둘 다 말이 된다.

그런데 이 둘은 동시에 참일 수 없다. 바스켓이 벨트에 있으면 벨트가 안 비었고, 벨트가 비면 되돌릴 바스켓도 사라진 뒤다. 교집합이 없어서 버튼은 영원히 안 눌렸다.

  • 조건이 화면과 엔진처럼 여러 층에 흩어져 있으면, “이 기능이 켜질 수 있는 상태가 존재하긴 하나”를 한 번은 겹쳐 봐야 한다
  • 오류 없이 아무 일도 안 일어나는 것이 가장 늦게 발견된다. 예외가 나는 편이 차라리 낫다

되돌릴 수 없으면 버튼을 막는 편이 낫다

되돌리기가 실시간과 부딪혔다. 이 게임은 바스켓을 올린 뒤에도 벨트가 돌고 모래가 계속 부어진다. 되돌리기 직전 상태를 통째로 저장해 뒀다가 복원하면, 그 사이 부어진 모래가 저장본에 없어서 통째로 사라진다.

총량이 어긋나면 그 레벨은 깰 수 없게 된다. 되돌리기 한 번이 판을 못 쓰게 만드는 것이다.

그래서 복원해도 되는 조건을 분명히 정하고, 그 조건을 못 채우면 버튼을 비활성으로 두기로 했다.

  • 되돌리기는 “그 사이 아무 일도 없었을 때만” 안전하다. 실시간에서는 그 조건이 대부분 안 맞는다
  • 조건을 못 채우면 기능을 막는다. 애매하게 실행해서 판을 깨뜨리는 것보다 안 되는 편이 낫다

밝게 보인다고 눌리는 건 아니다

튜토리얼에서 화면을 어둡게 덮고 눌러야 할 버튼만 밝게 뚫었다. 그런데 그 밝은 자리를 눌러도 반응이 없었다.

그리는 것과 터치를 받는 것은 별개다. 구멍은 그림이지 통로가 아니다. 덮개가 터치를 전부 가로채고 있었다.

  • 화면에서 보이는 것과 눌리는 것은 따로 논다. 뚫었으면 누를 수 있게 하는 처리를 따로 해 줘야 한다

남겨 두는 방식

값을 저장하지 말고 변화를 쌓는다

코인·하트 같은 재화를 지금 값으로 저장하지 않기로 했다. 대신 코인 +50·하트 -1 같은 변화 기록을 시간순으로 쌓아 두고, 전부 더해서 현재 값을 읽는다. 회계 장부와 같은 방식이고 append-only 원장이라고 부른다.

이렇게 두니 새로운 것이 생겨도 저장 구조를 안 바꿔도 됐다. 아이템 개수도, “이 레벨 보상을 이미 줬는가”도 기록 종류 하나만 늘리면 끝이었다.

  • 저장 구조를 바꾸는 일은 나중에 비싸진다. 무엇이 늘어날지 모를 때는 값이 아니라 변화를 쌓는 쪽이 여지를 남긴다
  • 대신 값을 읽을 때마다 전부 더해야 하므로, 기록이 무한정 늘어나는 종류에는 안 맞는다

확인할 수단이 없으면 대체 수단을 만든다

이 게임은 실시간으로 돌아가는데, 당시 환경에서는 기기에 올려 직접 해 볼 수가 없었다. 탭부터 완성까지 전체 흐름이 실제로 끝까지 가는지 확인할 방법이 없었다.

테스트 도구가 시간을 가짜로 흘려보낼 수 있다는 것을 쓰기로 했다. 실제로 기다리지 않고 “4초가 지났다고 치자”를 반복하면, 그 사이 게임 루프가 다 돌아 완성 화면까지 간다. 그게 뜨는지 보면 흐름 전체가 예외 없이 끝났다는 뜻이다.

  • 직접 확인할 수단이 막혀 있어도 대체 수단이 있는 경우가 많다. 없다고 넘기기 전에 한 번 찾아본다
  • 대체 수단은 진짜 확인만큼은 아니다. 흐름이 끝까지 가는지는 알려 주지만 눈으로 보이는 것이 맞는지는 못 알려 준다

Series: Today I Learn

1 C++ 자료형(Data Type) 2 MD5 vs pHash 3 C++에서 함수의 선언과 정의 4 Tkinter padx, pady 5 메모리와 포인터 변수 6 Call by Value, Call by Reference, Call by Pointer 비교 7 const 8 Gemfile — Jekyll 프로젝트의 의존성 파일 9 kramdown-parser-gfm — Jekyll의 GFM 파서 10 파서(Parser) 11 AHU vs OHU 12 I might try it vs I'll try it 뉘앙스 차이 13 SESSION_EXPIRE_AT_BROWSER_CLOSE=True 14 configuration key 15 Git stash vs discard 16 subprocess.Popen으로 Windows 탐색기에 명령어를 전달 17 Post 잔디 분석하기 18 Google Sheets Sync 최적화 19 DSL (Domain Specific Language)과 GPL (General Purpose Language) 20 마크다운 표 그리는 방법 21 쿼리 파라미터(Query Parameter). 기존 QR코드 재활용 22 Django 보안 취약점 점검 및 수정 23 OOP Object-Oriented Programming 객체 지향 프로그래밍 24 Fernet 대칭 암호화 25 Jekyll 코드블록 안의 Liquid 태그 26 insertOnConflictUpdate vs DoUpdate(target) 27 세션 필터 28 아코디언(Accordiaon) UI를 펼친상태로 만들기 29 input의 step 30 Word Cloud 31 Google Sheets를 데이터 버스로(with AppSheet) 32 Django 모델 텍스트 필드 자동 수집 패턴 33 localStorage로 섹션 토글 상태 유지 34 순차 ID 생성(`select_for_update()` + `max()` 조합) 35 역참조 검색과 distinct() 36 xlsx 다운로드와 로딩 오버레이 충돌 37 Android 파일 공유 MIME 타입 38 AssetManifest — Flutter 빌드 타임 asset 목록 런타임 조회 39 UTF-8 BOM과 PowerShell 파일 쓰기 40 소리꽃 KeyBloom TIL 1 41 소리꽃 KeyBloom TIL 2 42 소리꽃 KeyBloom TIL 3 43 메트로놈 Simple Metronome TIL 1 44 소리꽃 KeyBloom TIL 4 45 메트로놈 Simple Metronome TIL 2 46 소리꽃 KeyBloom TIL 5 47 메트로놈 Simple Metronome TIL 3 48 정적 블로그 SEO 정비와 Pagefind 검색 도입 49 메트로놈 Simple Metronome TIL 4 50 안드로이드 AudioTrack 연속 재생, 실측 피커 정렬, 카메라 토치 플래시 51 메트로놈 Simple Metronome TIL 5 52 모래게임 Sandrop TIL 1 읽는 중 53 메트로놈 Simple Metronome TIL 6 54 모래게임 Sandrop TIL 2 55 모래게임 Sandrop TIL 3 56 메트로놈 Simple Metronome TIL 7 57 모래게임 Sandrop TIL 4 58 모래게임 Sandrop TIL 5 59 모래게임 Sandrop TIL 6 60 소리꽃 KeyBloom TIL 6 61 모래게임 Sandrop TIL 7 62 모래게임 Sandrop TIL 8 63 소리꽃 KeyBloom TIL 7 64 모래게임 Sandrop TIL 9 65 소리꽃 KeyBloom TIL 8 66 모래게임 Sandrop TIL 10 67 소리꽃 KeyBloom TIL 9 68 소리꽃 KeyBloom TIL 10 69 소리꽃 KeyBloom TIL 11 70 소리꽃 KeyBloom TIL 12 71 소리꽃 KeyBloom TIL 13 72 온실 GreenHouse TIL 1 73 소리꽃 KeyBloom TIL 14 74 온실 GreenHouse TIL 2 75 소리꽃 KeyBloom TIL 15 76 소리꽃 KeyBloom TIL 16 77 소리꽃 KeyBloom TIL 17 78 소리꽃 KeyBloom TIL 18 79 소리꽃 KeyBloom TIL 19 80 소리꽃 KeyBloom TIL 20 81 로그스톤 상표 셀프 출원 TIL 1 82 소리꽃 KeyBloom TIL 21 83 소리꽃 KeyBloom TIL 22 84 소리꽃 KeyBloom TIL 23 85 소리꽃 KeyBloom TIL 24 86 MiniMacro TIL 1 87 Astro가 무엇인지, 왜 옮기는지 88 MiniMacro TIL 2 89 메트로놈 Simple Metronome TIL 8 90 메트로놈 Simple Metronome TIL 9 91 블로그 Astro 이관 TIL 92 오픈데이 Openday TIL 1 93 오픈데이 Openday TIL 2 94 Unreal Engine MCP TIL 1 95 콘티온 Conti On TIL 11 96 오픈데이 Openday TIL 3 97 오픈데이 Openday TIL 4 98 오픈데이 Openday TIL 5 99 ScorePlayer TIL 1 100 BuildingHub TIL 1 101 ScorePlayer TIL 2 102 BuildingHub TIL 2 103 ScorePlayer TIL 3 104 Unreal Engine MCP TIL 2 105 궁 미로 PalaceMaze TIL 1 106 메트로놈 Simple Metronome TIL 10 107 메트로놈 Simple Metronome TIL 11 108 오픈데이 Openday TIL 6 109 BuildingHub TIL 3 110 Obsidian vault에서 코드만 빼기 — directory junction 111 ScorePlayer TIL 4 112 오픈데이 Openday TIL 7 113 URL은 바뀔 수 있는 것에 묶지 않는다 114 도구는 기능이 아니라 내 일에 맞는지로 고른다 115 돌이키기 힘든 변경은 작게 먼저 확인한다 116 궁 미로 PalaceMaze TIL 3 117 궁 미로 PalaceMaze TIL 2 118 소리꽃 KeyBloom TIL 29 119 오픈데이 Openday TIL 8 120 BuildingHub TIL 4 121 ScorePlayer TIL 5 122 BuildingHub TIL 5 123 MiniMacro TIL 3 124 소리꽃 KeyBloom TIL 30 125 궁 미로 PalaceMaze TIL 4