자석을 한 번도 안 켜도 깨지는 벽돌깨기를 만들었다
Magnet Break는 패들이 움직이지 않는 벽돌깨기입니다. 화면 좌우를 눌러 자석을 켜고, 그 자력으로 날아가는 공을 휩니다. MVP를 끝내고 «자석을 한 번도 안 켜는 봇»을 돌렸더니 12스테이지 중 4개가 조작 0회로 클리어됐습니다.
이 게임이 하려던 말
규칙은 짧습니다. 패들은 폭 96으로 하단 중앙에 고정돼 있고 절대 움직이지 않습니다. 대신 화면 왼쪽 절반을 누르고 있으면 왼쪽 자석이 켜져 공에 수평 가속도 520 px/s²가 걸립니다. 오른쪽도 대칭입니다. 자기 에너지 게이지는 최대 100이고 자석을 켜는 동안 초당 34씩 줄고 끄면 초당 22씩 회복합니다. 0이 되면 1.2초 락아웃 — 그래서 «계속 당기고 있기»가 아니라 언제 당길지가 결정이 됩니다.
여기에 한 겹이 더 있습니다. 공은 항상 극성을 갖고, 왼쪽 자석을 켜면 N, 오른쪽 자석을 켜면 S가 됩니다. 파란 n 블록은 공이 N일 때만, 빨간 s 블록은 S일 때만 깨집니다. 그래서 이 게임의 핵심 갈등은 이 한 문장입니다 — 가고 싶은 방향과 필요한 극성이 어긋나는 순간. 왼쪽 목표 앞에 s 게이트가 있으면 왼쪽 자석을 켜는 순간 공이 N이 되어 게이트가 안 열립니다.
결국 만들고 싶었던 명제는 하나였습니다. «패들이 안 움직이니, 네가 자석을 켜지 않으면 아무 일도 일어나지 않는다.»
손으로는 절대 안 나오는 결함
MVP를 끝내고 12스테이지를 손으로 다 깨 봤습니다. «되네» 하고 넘어갔습니다. 문제는 만든 사람은 자석을 쓴다는 것입니다. 자석을 아예 안 쓰는 플레이는 시도조차 하지 않습니다 — 그게 이 게임의 전부니까요.
그래서 scripts/noinput-probe.mjs를 짰습니다. 하는 일은 단순합니다. 자석 입력을 한 번도 주지 않고 결정론적 물리 위에서 스테이지당 60초를 시뮬레이션합니다. 60초는 스테이지 제한시간보다 넉넉히 길게 잡았습니다 — 그래도 안 깨지면 확실히 안 깨지는 겁니다.
| 측정 | 결과 |
|---|---|
| 무입력(자석 0회) 클리어 | 4 / 12 |
| 해당 스테이지 | 5 · 7 · 8 · 10 |
핵심 메커닉이 게임의 3분의 1에서 없어도 되는 것이었습니다. 이건 «쉽다»가 아니라 «이 게임이 아니다»입니다.
내가 이미 «고쳤다»고 믿었던 것
더 뼈아픈 건, 이 문제를 나는 구현 중에 이미 한 번 만났고 이미 한 번 «처방»했다는 사실입니다.
원래 공은 완전 수직(vx=0)으로 발사됐습니다. 그런데 패들 반사각이 맞은 지점 오프셋으로만 정해지므로(중앙=수직, 끝=45도) 정중앙에서 수직으로 쏜 공은 정중앙 세로줄만 무한 왕복했습니다. 그리고 나는 모든 스테이지의 «클리어 보장 통로»를 정중앙 열에 뒀기 때문에, 컨트롤러를 놓고 있어도 목표 블록이 저절로 깨졌습니다. 테스트 스크린샷을 보다가 발견했습니다.
그때 넣은 처방이 대각선 발사였습니다. vx=±90, vy=−√(250²−90²), 스폰마다 좌우를 번갈아 — 속력 |v| = v0 = 250은 그대로 유지하면서 각도만 틀었습니다. 이러면 공이 정중앙에 갇히지 않습니다. 나는 이걸 «필수 수정»이라고 주석까지 달아 두고 넘어갔습니다.
무입력 봇의 재측정 결과는 여전히 4/12였습니다.
이유는 명확합니다. 대각선 발사는 공을 자유 궤도로 온 필드에 튀어다니게 만들 뿐입니다. 60초 동안 튀어다니다 보면 목표에 닿습니다. 즉 대각선은 무입력 클리어를 «확률 문제»로 바꿨을 뿐, 없애지 못했습니다.
이 프로젝트에서 가장 크게 남은 문장이 여기서 나옵니다 — 확률로 낮춘 결함은 고친 게 아니다. 계측을 안 했다면 나는 «대각선 넣었으니 됐다»로 끝냈을 겁니다. 주석까지 달아 뒀으니 더더욱.
처방 — 확률이 아니라 기하학
두 번째 처방은 방향이 반대입니다. 무작위성을 더하는 대신 빼고, 대신 레이아웃으로 보장했습니다.
- 공을 완전 수직(
vx=0)으로 되돌린다. 패들이 고정이고 반사각이 패들 중앙 기준 오프셋으로만 정해지므로, 정중앙에서 수직으로 쏜 공은 자석을 쓰지 않는 한 영원히 중앙 열만 왕복합니다 — 확률이 아니라 기하학적으로 보장됩니다. - 목표(
*)를 절대 중앙 열에 두지 않는다. 바깥 열(0~2, 6~8)에만 둡니다.
이 두 줄이 붙는 순간, 앞의 명제가 문장이 아니라 코드가 됩니다. 자석을 안 켜면 공은 중앙 열을 벗어날 방법이 없고, 목표는 중앙 열에 없습니다. 무입력 클리어는 «드물어진» 게 아니라 불가능해집니다.
여기서 레벨 설계 4원칙이 나왔습니다. ① 목표는 절대 중앙 열에 두지 않는다 ② 목표는 바깥 열에 ③ 목표 열 아래는 뚫려 있어야 한다 ④ 극성 게이트는 목표 바로 아래에.
한 방향만 재면 반드시 반대편이 부러진다
검증은 양방향으로 했습니다. 한쪽만 보면 «아무도 못 깨는 레벨»이나 «누구나 깨지는 레벨»로 아주 쉽게 기웁니다.
| 측정 | 재설계 전 | 재설계 후 |
|---|---|---|
무입력 클리어 noinput-probe.mjs | 4 / 12 | 0 / 12 |
봇 클리어 npm run verify:clear | 12 / 12 | 12 / 12 |
클리어 쪽 봇(verify-clear.js)은 이번에 극성 게이트를 다루도록 다시 썼습니다. 예전 봇은 «가장 가까운 목표의 세로줄로 조준»하는 단순 휴리스틱이라 극성이 어긋나는 스테이지를 못 풀었습니다. 새 봇은 사람이 쓰는 해법을 그대로 씁니다 — 진행 방향 자석으로 속도를 벌고, 목표에 가까워지면 반대쪽 자석을 짧게 터뜨려 극성만 뒤집은 뒤, 관성으로 흘려보냅니다. 봇이 그 해법을 써야만 통과한다는 사실 자체가, 그 해법이 실제로 존재한다는 증거이기도 합니다.
무입력을 0으로 만드는 동안 두 번 부러졌다
양방향으로 잰 게 실제로 값을 했습니다. 재설계 도중 클리어 가능성이 두 번 무너졌고, 둘 다 클리어 봇이 잡았습니다.
- 목표 열 아래를 막아 버렸다. 11·12스테이지에서 «목표가 벽 뒤에 있는 구조»를 만들려고 게이트 아래에 장식 블록을 깔았더니 접근 경로가 사라져 봇 클리어가 12/12에서 10/12로 떨어졌습니다. 게이트 아래 장식은 중앙 3열(3~5)로만 제한해 복구했습니다. 이게 원칙 ③이 생긴 이유입니다.
- 강화 블록을 목표 바로 위에 뒀다. 11스테이지에서 목표를 깬 공이 위에 있던 강화 블록(
H, 2히트)에 반복적으로 튕겨 목숨을 잃었습니다. 이건 «클리어 불가»는 아니지만 플레이어 입장에선 «깼는데 죽었다»입니다.H를 바깥 열(0·8)로 옮겨 해결했습니다.
덤으로, 필드가 너무 비어 «벽돌깨기»로 안 읽히는 문제가 생겨 일반 블록(#)으로 밀도를 올렸습니다. 일반 블록은 깨도 되고 안 깨도 되므로 클리어 조건에는 영향이 없습니다 — 목표 블록만 다 깨면 클리어입니다.
회귀로 못 박기
이런 종류의 결함은 «한 번 고쳤다»로 끝나지 않습니다. 레벨을 한 줄만 손대도 다시 들어옵니다. 그래서 test/game.spec.js에 「레벨 설계 불변식」 두 케이스를 넣었습니다. 레벨 문자열 그리드를 건드리면 이 테스트가 먼저 깨집니다.
재설계는 기존 테스트도 하나 건드렸습니다. 극성 테스트 2건이 게이트를 위에서 때리도록 되어 있었는데, 재설계로 게이트 바로 위에 목표 블록이 붙으면서 공이 목표를 먼저 때려 실패했습니다. 아래에서 올라가며 때리도록 픽스처와 단언 방향을 함께 바꿨습니다 — 규칙 자체는 그대로입니다. 현재 24케이스 전부 통과합니다.
남는 생각
«이 게임의 핵심 메커닉이 정말 필수인가»는 밸런스 질문처럼 보이지만 사실 이진 질문입니다. 그리고 이진 질문에는 봇 한 대면 답이 나옵니다. 무입력 봇은 30줄이 안 됩니다.
그래서 이제 게임을 내기 전에 «이길 수 있는가»보다 먼저 묻습니다 — «이 규칙 없이도 이길 수 있는가?» 앞의 질문은 게임이 성립하는지를 묻고, 뒤의 질문은 게임이 그 게임인지를 묻습니다.
직접 해 보기
다른 제작 노트
이 글의 모든 수치는 레포의 SPEC.md §9와 실제 계측 스크립트 출력에서 그대로 가져왔습니다. 사실과 다른 부분을 발견하면 문의로 알려 주세요.