커밋 기록으로 되짚는 Salmonholic 버그 사냥 다섯 건
주사율에 따라 달라지는 판정, 재접속하면 끝나지 않는 관전, 좌우로 떠는 봇, 처리되지 않은 오디오 오류, 끝나지 않는 경기. Salmonholic을 만들며 잡은 버그 다섯 개를 증상과 원인, 수정 순서로 정리했다.

혼자 게임을 만들면 버그 보고서를 쓰는 사람도 나고 읽는 사람도 나다. 그래서 Salmonholic은 버그를 잡을 때마다 증상, 재현 방법, 원인, 수정 후 검증을 QA 폴더에 남기고, 커밋 메시지에 한 줄로 요약해 두었다. 9월 24일부터 26일 사이에 잡은 버그 중 기억에 남는 다섯 개를 골랐다.
한눈에 보는 다섯 건
| # | 커밋 | 증상 | 원인 | 수정 |
|---|---|---|---|---|
| 1 | 62a2f75 | 모니터 주사율마다 오프라인 경기 결과가 다름 | 시뮬레이션이 렌더 프레임마다 한 번씩 돌았음 | 60Hz 고정 누산기 |
| 2 | 62a2f75 | 재접속 뒤 죽었는데 탈락 안내·결과가 안 뜸 | 놓친 사망 이벤트를 복구하지 않음 | 스냅샷에서 사망 상태 복원 |
| 3 | 4fe87ef | 봇이 제자리에서 좌우로 떪 | 0.22초마다 목표를 새로 고름 | 목표 유지와 도착 감속 |
| 4 | dd0d15e | 포털에서 처리되지 않은 오류 기록 | 오디오 재개 Promise의 거부를 안 받음 | 예상된 거부만 처리 |
| 5 | 95bbd5b | 마지막 사람이 죽어도 경기가 600초까지 안 끝남 | 사망 처리 중 포털 호출에서 함수가 중단됨 | 유효성 검사 추가 |
1. 165Hz 모니터에서만 더 잘 되는 게임
CrazyGames 제출 요건을 점검하다가 발견했다. 오프라인 연습 경기는 같은 시드를 넣으면 같은 결과가 나와야 하는데, 헤드리스로 프레임률만 바꿔 120초를 돌려 보니 숫자가 전부 달랐다.
| 프레임률 | 수정 전 처치 / 경험치 | 수정 후 처치 / 경험치 |
|---|---|---|
| 60 | 2233 / 2078 | 2233 / 2078 |
| 120 | 2453 / 2301 | 2233 / 2078 |
| 144 | 2179 / 1949 | 2233 / 2078 |
| 165 | 3178 / 2934 | 2233 / 2078 |
165Hz에서는 처치 수가 40% 넘게 많았다. 시뮬레이션이 화면을 그릴 때마다 한 번씩 진행돼서 주사율이 곧 게임 속도와 판정 횟수가 된 것이다. 오프라인과 봇 폴백을 60Hz 누산기로 바꿨다. 쌓인 시간만큼 1/60초 단계를 여러 번 돌리고, 탭을 오래 비웠다 돌아와도 한 프레임에 최대 0.25초(15단계)까지만 처리한다. 수정 후에는 네 주사율에서 전체 지표와 10명의 좌표·경험치까지 완전히 같아졌다. 온라인은 원래 서버가 판정하므로 영향이 없었다.
2. 재접속하는 사이에 죽으면
같은 날 브라우저 반복 테스트에서 나온 버그다. 온라인 경기 중 연결이 잠깐 끊기고, 그 사이에 내 고양이가 죽는다. 다시 연결되면 서버는 사망 이벤트를 다시 보내지 않고 alive=false가 담긴 키프레임만 보낸다. 캐릭터는 쓰러지는데 사망 시각 변수가 -1로 남아 있어서 탈락 안내도, 관전 종료 타이머도 시작되지 않았다. 서버가 경기를 끝낼 때까지 결과 화면으로 넘어가지 못했다.
덤으로 하나 더 있었다. 레벨 업 카드가 열린 상태에서 죽으면 카드가 닫히지 않고 남았다.
수정 후 모바일 가로 반복 테스트 화면. 탈락하면 남은 고양이를 관전하고, 결과로 이동 버튼이 뜬다.
재현은 실제 스냅샷 처리 함수를 직접 부르는 테스트 스크립트로 만들었다. 수정 전 로그에는 alive=false hero_dead_t=-1.0 card_pending=true가 그대로 찍혔다. 이제 스냅샷에서 본인 사망을 발견하면 사망 시각과 관전 타이머, 경기 종료 상태를 복원하고 카드를 닫는다. 다만 킬 피드와 사망 원인은 이벤트를 놓쳐서 알 수 없으니 지어내지 않고 비워 둔다. 소켓을 강제로 끊는 테스트 4경기에서 재접속이 모두 한 번에 성공했다.
3. 좌우로 떠는 봇
빈자리를 채우는 봇이 제자리에서 왼쪽, 오른쪽으로 계속 몸을 돌렸다. 처음엔 네트워크 보간 문제라고 의심했지만, 서버 원본 좌표에서부터 방향 역전이 보여서 그 가설은 버렸다.
원인은 서버 봇의 “사냥터 고르기”였다. 봇은 0.22초마다 12방향 × 2반경 후보의 먹이 밀도를 다시 평가했는데, 밀도 계산이 봇 자신을 중심으로 움직이는 방식이라 비슷한 최댓값이 양쪽에서 번갈아 나왔다. 목표가 초당 약 3.7번 바뀌었다. 목표를 도착할 때까지 유지하고, 이전 진행 방향에 최대 10% 가산점을 주고, 목표 근처에서는 속도를 줄이게 바꿨다. 위험 회피나 교전은 여전히 목표보다 우선한다.
| 서버 시드 16 | 수정 전 | 수정 후 |
|---|---|---|
| 초당 방향 역전 | 2.809 | 0.045 |
| 초당 좌우 반전(표시) | 2.641 | 0.182 |
| 순변위 / 이동 거리 | 30.5% | 95.9% |
봇 동선이 바뀌면 적과 만나는 순서와 난수 소비도 바뀐다. 그래서 이때는 결정론 테스트의 기준 결과(골든)를 의도적으로 새로 만들었다. 봇 이야기는 빈자리 봇과 매치메이킹 글에 더 자세히 있다.
4. 소리가 켜지지 않았다는 이유의 오류
CrazyGames 대시보드에 게임플레이 오류 비율이 잡혀 있었다. 원인을 좁히다 보니 오디오 재개 코드가 눈에 들어왔다. 브라우저는 사용자 입력 없이 소리를 켜지 못하게 막는데, 엔진 템플릿은 AudioContext.resume()이 돌려주는 Promise를 처리하지 않았다. 정책 거부를 주입하면 NotAllowedError가, 이미 닫힌 컨텍스트를 다시 켜려 하면 InvalidStateError가 처리되지 않은 거부로 튀어나왔다.
0.16.2에서 재개 경계에서만 이 예상된 오류 세 종류를 받아 다음 입력 때 다시 시도하도록 했다. 전역에서 모든 오류를 삼키지는 않았다. 일부러 넣은 알 수 없는 오류는 그대로 한 번 보고되는 것까지 확인했다. 변경 전 데스크톱과 느린 모바일 조건에서 각각 2건이던 오디오 미처리 거부가 0건이 됐다. 이것이 대시보드 오류율의 전부라고 단정하지는 않았다. 포털이 오류를 어떻게 세는지 공개 문서로 확인할 수 없었기 때문이다.
5. 끝나지 않는 경기
마지막은 신입 전용 방 밸런스를 시뮬레이션으로 훑던 중에 나왔다. 몇몇 시드에서 경기가 600초 제한까지 끝나지 않았다. 실패한 10개 시드는 모두 “사람 2위, 사망 기록 8개”라는 같은 모양이었다. 마지막 사람의 사망이 기록되기 전에 무언가 멈췄다는 뜻이다.
추적해 보니 작업 중이던 포털 스크립트에 컴파일 오류가 있어 포털 자동 로드가 없었고, 사망 처리 함수 kill_player 중간의 포털 호출에서 실행이 중단됐다. 생존자 수는 이미 1로 줄었지만 ended=true까지 가지 못했고, 서든데스는 생존자가 2명 이상일 때만 동작하니 경기는 그대로 멈춰 있었다. 문제의 오류는 커밋된 적 없는 일시적 상태였지만, 같은 일이 다시 생겨도 경기는 끝나야 한다. 포털 호출 앞에 유효성 검사를 넣고, 종료·결과 전환 회귀 12개를 만들었다. 90판 스윕에서 모두 마지막 사망 프레임에 경기가 끝났다. 시뮬레이션 스크립트도 이제 이런 상태를 발견하면 실패로 끝나서, 멈춘 경기 통계가 정상 결과처럼 섞이지 않는다.
기록을 남겨서 좋았던 점
다섯 건 모두 “증상 → 재현 → 원인 → 수정 → 검증”의 흐름이 기록에 남아 있다. 덕분에 이 글도 기억이 아니라 로그를 보고 쓸 수 있었다. 특히 도움이 된 건 두 가지다. 같은 시드로 같은 결과가 나오는지 확인하는 골든 테스트가 1번과 3번을 잡거나 검증했고, “무엇을 확인하지 못했는지”를 따로 적어 둔 덕에 4번처럼 원인을 과장하지 않을 수 있었다.
모바일 쪽에서 나온 문제들은 휴대폰 세로 화면 대응기에 따로 정리했다. 버그를 잡은 결과물은 Salmonholic 게임 페이지에서 직접 플레이해 볼 수 있다.


