flowoodz.
← blog
20분 읽기글 flowoodz

보스전이 320초를 넘긴 이유는 밸런스가 아니었습니다: Tidebreaker에 에이전트용 window.__game 훅을 심어 찾은 한 줄 버그

6웨이브 아레나 슈터 Tidebreaker에 에이전트가 읽는 window.__game 훅을 심고 봇에게 직접 플레이를 맡긴 기록입니다. 800개 엔티티 스트레스 수치와, 밸런스 탓으로 보였던 보스전 루프가 레벨업 뒤 연사를 끄는 한 줄 때문이었다는 것을 적었습니다.

목차
  1. 1라운드는 증거가 없어서 떨어졌습니다
  2. 훅은 읽기만 하고, 조작은 진짜 입력으로
  3. 800개를 띄우는 ~ 키
  4. 밸런스 문제로 보였던 보스전 320초
  5. 고친 건 한 줄이었습니다
  6. 남은 것

Tidebreaker는 제가 만든 6웨이브 아레나 슈터입니다. 심해 아레나에서 파편 모양 적의 밀물을 막고, 마지막 웨이브의 보스 Tidemaw를 쓰러뜨리면 한 판이 끝납니다.

tidebreaker.flowoodz.com에서 바로 할 수 있습니다.

Vite, TypeScript, PixiJS v8로 만들었고 이미지 파일과 사운드 파일이 하나도 없습니다. 도형은 코드로 그리고 소리는 WebAudio로 합성합니다.

2026년 7월 20일부터 23일까지 에이전트 루프로 만들었습니다. 계획, 구현, 평가를 서로 다른 에이전트가 맡았고, 평가 에이전트는 구현 에이전트의 말을 믿지 않고 디스크에 남은 증거만 채점했습니다.

이 글은 그 채점을 통과하려고 게임에 심은 window.__game 훅과, 그 훅으로 찾은 버그 이야기입니다.

1라운드는 증거가 없어서 떨어졌습니다#

평가는 여섯 축을 10점 만점으로 매기고, 8점 이하가 하나라도 있으면 그 라운드를 실패로 봅니다. 1라운드는 증거 이행 3점, 성능 5점이었습니다. 게임은 돌아갔는데 남은 증거가 스크린샷 13장뿐이었습니다.

성능 5점의 이유는 지금 다시 읽어도 따끔합니다. 구현 에이전트가 커밋 0389c70 메시지에 적은 숫자를 평가 에이전트는 이렇게 판정했습니다.

커밋 메시지 "stress reaches 823 entities"는 주장일 뿐 로그 실물 없음.

연출 점수도 같은 이유로 깎였습니다. 보스 등장 스크린샷이 전부 상태를 강제로 바꿔서 찍은 것이었습니다. 평가 계획에는 처음부터 "자연 발생 t= 스샷, forced-ui-only 라벨 금지"가 적혀 있었습니다.

게임에는 이미 디버그 훅이 있었습니다. jump(w)는 원하는 웨이브로 건너뛰고, bossHp(n)은 보스 체력을 바꾸고, die()는 플레이어를 죽입니다. 셋 다 상태를 바꾸는 훅이라 자연 증거를 만들 수 없었습니다.

자연 증거를 얻으려면 누군가 웨이브 1부터 진짜로 플레이해야 했습니다. 2라운드 QA 에이전트는 봇을 직접 돌리기로 했고, 봇이 게임을 하려면 화면 대신 읽을 상태가 필요했습니다.

훅은 읽기만 하고, 조작은 진짜 입력으로#

7월 23일 새벽에 구현 에이전트가 훅 네 개를 차례로 넣었습니다.

커밋 시각 훅 돌려주는 값
cb47d7e 00:54 pos() 플레이어 좌표
d9b4737 00:58 threats() 근처의 적과 적탄
6387b1d 01:38 cards() 레벨업 카드 이름
38bda57 02:36 phase() 보스 등장 연출 단계

pos(), cards(), phase()는 한 줄씩입니다. 가장 긴 threats()도 15줄입니다.

threats: () => { // autopilot verification only: nearby enemies + enemy bullets
  const es: { x: number; y: number; d: number }[] = []
  for (const e of enemies) {
    if (e.state !== 2) continue
    const d = Math.hypot(e.x - px, e.y - py)
    if (d < 320) es.push({ x: e.x, y: e.y, d })
  }
  const bs: { x: number; y: number; vx: number; vy: number; d: number }[] = []
  for (const b of ebullets) {
    if (!b.on) continue
    const d = Math.hypot(b.x - px, b.y - py)
    if (d < 240) bs.push({ x: b.x, y: b.y, vx: b.vx, vy: b.vy, d })
  }
  return { es, bs }
},

플레이어에서 320px 안의 적과 240px 안의 적탄만 돌려주고, 적탄은 속도 vx, vy까지 넘깁니다. 복잡도 리뷰는 반경을 코드에 고정값으로 적어 둔 것을 "검증 전용 훅, 설정화가 yagni"라며 넘겼습니다.

규칙은 하나였습니다. 훅은 상태를 읽기만 하고, 조작은 진짜 입력 이벤트로만 합니다. QA 에이전트는 실제 Chrome(GPU)에 playwright-core로 붙어 pointermove, keydown, keyup만 보냈고, 2라운드에서 약 75분 동안 30판쯤 돌렸습니다.

봇이 입력 이벤트만 보내고 window.__game 읽기 훅으로 상태를 읽는 구조, 쓰기 훅을 쓴 장면은 -forced 라벨

쓰기 훅을 쓴 장면에는 라벨을 달았습니다. 2라운드 승리 화면은 jump(6)으로 마지막 웨이브에 들어가서 찍었기 때문에 -forced가 붙었습니다. 평가 에이전트는 이걸 조각을 이어 붙인 경로로 보고, 기능 점수가 9점에 그친 이유 중 하나로 들었습니다.

phase()는 놓친 장면 때문에 생겼습니다. 보스가 나올 때 화면 아래에서 붉은 파티클 벽이 올라오는데, 이 벽은 연출 시작 0.8초부터 2.0초 사이에만 화면에 있습니다.

그런데 보스 좌표를 주는 boss() 훅은 1.4초부터 값을 돌려줬고, 2라운드에서 파일 이름에 wall을 붙여 찍은 스크린샷 2장에는 벽이 없었습니다. 평가 에이전트는 벽이 안 보인다고 감점했고, QA 기록은 원인을 이 캡처 타이밍으로 봤습니다.

같은 커밋에 적탄이 대시에 갈리는 순간 [split] 로그를 남기는 줄도 넣었습니다. 3라운드 봇은 이 로그가 뜨자마자 캡처해서, 두 라운드 연속 식별이 안 됐던 탄 가르기 장면을 얻었습니다.

800개를 띄우는 ~ 키#

성능 기준은 적, 탄, 파티클을 합쳐 800개가 동시에 떠 있을 때 p95 프레임 시간 16.7ms 이하였습니다. 이걸 재려고 7월 21일 0시 1분 커밋 68f03d4에서 ~ 키로 켜는 스트레스 모드와 window.__metrics를 넣었습니다.

12분 뒤 커밋 0389c70에서 스폰 규칙을 고쳤습니다. 처음엔 적탄을 아레나 정중앙에서 쐈는데, 주석대로라면 그 탄이 플레이어 판정에 바로 먹혔습니다. 그래서 반지름의 0.7배 되는 원 위에서 쏘도록 바꿨습니다.

-        if (ebn < 60) for (let i = 0; i < 12; i++) fireEB(CX, CY, Math.random() * TAU, 150 + Math.random() * 100)
+        for (let i = 0; ebn < 110 && i < 30; i++, ebn++) {
+          const sa = Math.random() * TAU // from arena ring, random heading (center-spawn gets eaten by the player hitbox)
+          fireEB(CX + Math.cos(sa) * Ra * 0.7, CY + Math.sin(sa) * Ra * 0.7, Math.random() * TAU, 150 + Math.random() * 100)
+        }

스트레스 모드는 플레이어를 계속 무적으로 둡니다. 주석에 적힌 이유는 측정하는 판이 끝까지 살아남게 하려는 것입니다.

QA 로그에 남은 숫자는 이렇습니다. 스파이크는 일반 전투 부하, 2라운드와 3라운드는 ~ 키로 잰 값입니다.

측정 엔티티 avg p95 max
스파이크 (7월 20일) 활성 적 약 24 + 탄·파티클 10ms 10.8ms 11.1ms
2라운드 (7월 23일) 최대 828 10.0ms 11.6ms 12.1ms
3라운드 (버그 수정 후) 최대 826 10.0ms 11.7ms 12ms

828개를 나눠 보면 적 220, 적탄 107111, 파티클 477498이었습니다. 800개 중 절반 이상이 파티클입니다. 기준 문장에 파티클이 들어 있으니 위반은 아니지만, 적 800마리를 떠올렸다면 전혀 다른 숫자입니다.

이 숫자를 읽을 때 조심할 게 두 가지 있습니다. 하나는 화면 주사율입니다. 스파이크 기록에 측정 화면이 100Hz였다고 적혀 있고, 2·3라운드 로그의 마지막 600프레임 avgFps도 100입니다.

평균 10.0ms는 주사율에 묶인 값으로 보입니다. 800개를 그리는 데 실제로 몇 ms가 드는지는 이 로그로 알 수 없고, 100Hz 한 프레임 안에 들어온다는 것까지만 말할 수 있습니다.

다른 하나는 토글 직후 구간의 maxMs 100입니다. 프레임 시간을 기록하는 코드가 Math.min(now - last, 100)으로 값을 자르기 때문에, 100은 측정값이 아니라 상한입니다. "100ms 이상 걸린 프레임이 있었다"로 읽어야 합니다.

이 글을 쓰면서 10월 17일에 라이브 사이트를 다시 열어 봤습니다. M2 Max 맥에서 Playwright 1.64의 headless Chromium으로 접속해 콘솔 값을 읽었습니다. 훅은 그대로 살아 있었고, __game에 함수 13개, __metrics에 항목 11개가 있었습니다.

타이틀을 한 번 클릭하고 __game.stress()를 부른 뒤 1초마다 __metrics.entities()를 읽었습니다. Metal GPU 렌더러로 두 번 돌렸고, 1초마다 읽은 값과 게임이 2초마다 남기는 [stress] 로그가 791~821개 사이였습니다. 엔티티 수는 7월 기록과 같은 범위로 재현됩니다.

Tidebreaker 스트레스 모드. 흰 플레이어 주위로 육각형, 별, 파편 모양 적이 몰리고 바깥에 분홍 적탄이 흩어져 있습니다

위 캡처가 그 실행에서 찍은 화면이고, 찍기 직전에 읽은 엔티티 수는 815개였습니다.

프레임 시간은 재현하지 못했습니다. 두 실행에서 avg 4248ms, p95 83100ms가 나왔습니다. headless 기본값인 SwiftShader 소프트웨어 렌더러로 돌렸을 때는 페이지를 연 뒤 스트레스 8초가 지날 때까지 기록된 프레임이 21개뿐이었습니다.

게다가 600프레임 창에는 스트레스를 켜기 전 타이틀 화면 프레임도 섞여 있어서 깨끗한 측정이 아닙니다. 느린 원인이 headless의 프레임 스케줄링인지 렌더러인지는 가려내지 못했습니다. 그래서 이 글의 성능 수치는 7월 QA 로그만 인용합니다.

밸런스 문제로 보였던 보스전 320초#

2라운드 봇은 30판을 돌면서 한 번도 이기지 못했습니다. 30판 중 19판이 웨이브 4~5에서 끝났고, 보스까지 간 판은 3판이었습니다. 가장 긴 판은 보스전만 320초 넘게 버티다 죽어서 전체가 503.6초였습니다.

QA 에이전트는 이걸 밸런스 문제로 적었습니다. 남은 잡몹이 총알을 먼저 맞아서 보스에게 가는 피해가 0에 가까워진다는 설명이었습니다.

평가 에이전트도 이 설명을 받아들였습니다. 손맛·지루함 축에 실패 점수인 8점을 줬고, 잡몹 스폰 제한이나 관통 같은 밸런스 수정을 다음 라운드 지침으로 남겼습니다.

반대 증거는 같은 기록 안에 있었습니다. jump(6)으로 마지막 웨이브에 바로 들어간 판에서는 봇이 보스 체력 훅 없이 43초 만에 보스를 잡았습니다. 그 판은 레벨업을 한 번도 하지 않은 판이었습니다.

3라운드 QA는 진단용 텔레메트리를 따로 돌렸습니다. 첫 레벨업 직후부터 플레이어 탄 수가 0에서 움직이지 않았습니다. 보스전에서는 잡몹이 0마리이고 조준도 보스를 향하는데 보스 체력이 300에서 멈춰 있었습니다.

pointerup 이벤트를 따로 찍어 봐서 입력이 사라진 것도 아니라는 걸 확인했습니다. 원인은 레벨업 화면을 여는 함수에 있었습니다.

  • 마우스 버튼을 누르면 firing이 true가 되고, 떼면 false가 됩니다.
  • 레벨업 화면을 여는 openLevelup()이 firing = false를 실행했습니다.
  • 레벨업 중에 누른 클릭은 카드 선택으로만 처리되고 firing을 켜지 않습니다.
  • 레벨업을 닫는 closeLevelup()은 firing을 되돌리지 않았습니다.

Tidebreaker의 발사는 마우스 버튼을 누르고 있는 동안 계속 나가는 방식입니다. 그래서 버튼을 누른 채 레벨업을 지나면, 손을 뗐다가 다시 누르기 전까지 총이 나가지 않았습니다.

진단 기록에서 탄 수가 계속 0이었으니, 봇은 첫 레벨업이 끝난 뒤 전투 중에 버튼을 다시 누르지 않은 것입니다. 3라운드 기록은 웨이브 4~5에서 무더기로 죽은 것도, 보스전이 끝나지 않은 것도 이 한 가지로 설명합니다.

고친 건 한 줄이었습니다#

커밋 e6c5882(7월 23일 3시 14분)의 diff 전체입니다.

@@ -1248,8 +1248,7 @@ async function main() {
     return [-x + c, -y, x - c, -y, x, -y + c, x, y - c, x - c, y, -x + c, y, -x, y - c, -x, -y + c]
   }
   function openLevelup() {
-    state = 'levelup'
-    firing = false
+    state = 'levelup' // update() early-returns in levelup — held LMB resumes autofire on close (pointerup listener handles release)
     xp -= xpNeed
     luT = 0; luPick = -1; luPickT = 0
     // pick 3 distinct upgrades

통계로는 한 줄 추가, 두 줄 삭제지만 실제로 지운 건 firing = false 한 줄이고, 나머지는 같은 줄에 주석을 단 것입니다.

레벨업 중에는 update()가 카드 애니메이션만 돌리고 바로 반환하기 때문에, 이 플래그는 레벨업 중 발사를 막는 데 아무 역할도 하지 않았습니다. 남은 효과는 버튼을 누른 채 레벨업을 지난 플레이어의 연사를 멈추는 것 하나뿐이었습니다. 커밋 메시지도 같은 말을 합니다.

수정 직후 첫 판에서 봇이 처음으로 상태를 건드리지 않고 이겼습니다. 타이틀에서 시작해 웨이브 6과 보스까지 한 번에 간 판입니다.

항목 값
런 길이 192.0초 (목표 180~300초)
레벨업 8회 (분당 2.5회)
처치 254
보스전 약 20초
콘솔 에러 0

3라운드는 여섯 축 모두 10점으로 통과했습니다. 2라운드 지침대로 잡몹 스폰부터 줄였다면, 총이 안 나가는 버그 위에 밸런스를 덧칠하고 보스전이 왜 여전히 안 끝나는지 다시 헤맸을 거라고 생각합니다.

봇은 전투 중에 버튼을 다시 누르지 않았으니 레벨업을 지날 때마다 이 버그에 걸렸을 것입니다. 사람 테스터였다면 카드를 고른 뒤 습관처럼 다시 클릭해서 못 보고 지나쳤을 수도 있습니다. 둘 다 제 추측이고, 사람 플레이로 확인한 적은 없습니다.

남은 것#

  • 훅은 라이브 빌드에 그대로 있습니다. 코드 주석에는 "remove if they leak into scoring"이라고 적혀 있습니다. 리더보드는 처음부터 범위 밖이라 지금은 조작할 점수가 없지만, 순위표를 붙이는 날에는 die, jump, bossHp부터 빼야 합니다.
  • 2라운드 복잡도 리뷰에서 nearest()가 threats()와 겹쳐 8줄쯤 줄일 수 있다고 했는데, 적용하지 않은 채 배포됐습니다.
  • 봇 스크립트는 저장소에 남아 있지 않습니다. 훅과 로그는 있지만 30판 캠페인을 같은 조건으로 다시 돌릴 수는 없습니다. 다음 게임에서는 봇 코드를 훅과 같은 커밋에 넣어 두는 편이 맞겠습니다.
  • 오늘 headless에서 프레임 시간이 7월 값과 다르게 나온 이유는 미결입니다.