세 번째 게임 — 조이스틱 세로 스크롤 슈팅
맥주 게임기에 블랙잭을 꽂으면서 3부에서 “게임기가 플랫폼이 되는 순간”이라고 썼습니다. 그 말에 책임을 질 차례가 왔습니다. 세 번째 게임으로 세로 스크롤 슈팅을 넣었습니다.
이번에도 입력 장치가 게임을 정했습니다. 레버가 맥주 게임을 만들었고 물리 버튼이 블랙잭을 만들었듯, 이번엔 아날로그 조이스틱이 먼저 있었습니다. 상하좌우로 자유롭게 움직일 수 있는 입력이 생기니 자연스럽게 슈팅이었습니다. 레퍼런스는 국민 게임 〈드래곤 플라이트〉로 잡았습니다 — 세로 스크롤, 자동 발사, 아이템으로 강해지기, 죽을 때까지 계속.
만들고 싶은 그림은 분명했습니다. 밤하늘을 끝없이 올라가며, 쏟아지는 적을 조이스틱으로 피하고 총으로 쓸어담고, 아이템을 먹어 점점 강해지다, 위기엔 폭탄으로 화면을 정리하는 — 짧게 치고 빠지지만 “한 판만 더” 하게 되는 아케이드 슈팅입니다. 다만 원작은 좌우로만 움직이는데, 조이스틱을 단 이상 상하도 쓰지 않을 이유가 없었습니다.

밤하늘을 올라가며 적을 쏩니다. 우측 패널에 점수·목숨·파워·폭탄. 조이스틱으로 비행기를 움직이고 총은 자동으로 나갑니다.
메모리 주소부터 없었다
메모리 주소가 게임 로직보다 먼저 발목을 잡았습니다.
이 게임기는 컨트롤러가 게임을 계산하고 화면이 그것을 그리는 구조입니다. 둘은 표준 통신으로 연결되는데, 화면이 읽을 수 있는 주소 범위가 정해져 있습니다. 그 범위가 이미 맥주와 블랙잭으로 차 있었습니다. 남은 빈칸이 두 덩어리로 쪼개져 있어서, 슈팅게임이 매 순간 보내야 하는 비행기·적·아이템 좌표를 한 번에 읽을 연속된 자리가 없었습니다.
읽기 영역과 쓰기 영역의 경계를 옮겨서 공간을 넓혔습니다. 대신 대가가 있었습니다 — 잘 돌고 있는 게임 두 개의 명령 주소를 전부 이사시켜야 했습니다. 시작·종료·베팅 같은 버튼이 전부 다른 번지로 옮겨간다는 뜻입니다.
그래서 순서를 지켰습니다. 주소 이사만 먼저 하고, 슈팅은 한 줄도 건드리지 않은 채 맥주와 블랙잭을 각각 끝까지 한 판씩 돌렸습니다. 두 게임이 멀쩡한 걸 확인하고 나서야 새 게임 작업을 시작했습니다. 섞어서 했으면 문제가 생겼을 때 원인을 가릴 수 없었을 것입니다.
총알 좌표를 아끼려다 인과가 깨졌다
좌표 메모리가 빠듯하다 보니 아낄 곳을 찾았습니다. 눈에 띈 게 제 총알이었습니다.
총은 자동으로 나갑니다. 발사 주기가 정해져 있으니 화면 쪽에서 같은 규칙으로 궤적을 그릴 수 있습니다. 그러면 총알 좌표를 보낼 필요가 없습니다. 컨트롤러는 “몇 번째 발사인지”만 알려주고, 명중 판정은 자기 안에서 하면 됩니다. 자리를 스무 칸 넘게 아꼈습니다.
문제는 그 명중 판정을 발사하는 순간에 해버렸다는 것입니다. 총알이 날아가는 시간이 0이니, 화면 꼭대기에 있는 적도 방아쇠를 당긴 즉시 터집니다. 화면에는 총알이 이제 막 출발했는데 적은 벌써 사라진 뒤입니다. 플레이어 눈에는 “총알이 닿지도 않았는데 폭발했다”로 보입니다. 가장 먼 적은 0.4초나 먼저 죽었습니다.
고친 방법은 단순했습니다. 총알을 컨트롤러 내부에서만 실제로 날렸습니다. 좌표를 화면에 보내지 않으니 자리는 그대로 아끼면서, 명중은 총알이 도착했을 때 일어납니다. 화면이 그리는 속도와 내부에서 나는 속도를 같은 값으로 맞춰서 시간축까지 일치시켰습니다.
스프라이트 시트는 다시 생성 AI에게
스프라이트 시트는 4부에서 바텐더를 만들 때 쓴 방법을 그대로 썼습니다. 브라우저를 열어 생성 AI에게 스프라이트 시트를 주문하는 것입니다. 한 대화에서 이어 요청하면 그림체가 유지됩니다.

한 번에 다섯 개를 요청해서 받은 시트입니다. 플레이어기, 소형·중형·대형 적, 파괴 불가 암석. 따로따로 주문하면 그림체가 제각각이 됩니다.

파워업 캡슐, 폭탄, 그리고 폭발 4단계. 배경을 연한 회색 단색으로 지정해두면 나중에 투명하게 벗겨내기가 깔끔합니다.
이번엔 두 가지가 걸렸습니다.
첫째, 첫 요청이 거부당했습니다. “저는 언어 모델로서 그것을 도와주도록 설계되지 않았습니다.” 프롬프트가 문제인 줄 알고 표현을 바꿔볼 뻔했는데, 화면을 자세히 보니 원인이 따로 있었습니다 — “현재 Pro에 대한 수요가 높습니다. 이 대답에는 다른 모델이 사용되었으며”. 이미지를 만들 수 있는 모델이 붐벼서 글만 쓰는 모델로 대체됐고, 그 모델이 “나는 그림을 못 그린다”고 답한 것이었습니다. 모델을 다시 지정하니 바로 나왔습니다. 거부 문구만 보고 프롬프트를 고쳤으면 엉뚱한 데서 헤맸을 것입니다.
둘째, 적기가 전부 위를 향해 그려졌습니다. “아래를 향한 탑뷰”라고 분명히 적었는데도 플레이어기와 같은 방향이었습니다. 내려오는 적이 위를 보고 있으면 게임이 성립하지 않습니다. 다시 주문하는 대신 후처리에서 적기만 180도 돌렸습니다. AI에게 다시 설명하는 것보다 한 줄 코드가 빨랐습니다.
적 낙하 속도가 너무 빨랐다
적 낙하 속도는 돌려보니 무섭게 빨랐습니다. 재보니 직진형 적이 화면을 2.2초에 통과했습니다. 피할지 쏠지 판단할 시간이 없습니다. 가만히 서 있으면 10초 만에 목숨 세 개가 다 날아갔습니다.
속도를 절반으로 낮추려는데 사소한 벽이 있었습니다. 속도가 “한 틱에 몇 픽셀”인 정수라 3의 절반인 1.5를 표현할 수가 없었습니다. 비행기 이동에 이미 쓰고 있던 소수점 누적 방식을 적에게도 적용했습니다 — 매 틱 0.1픽셀 단위로 쌓아두고 1픽셀이 넘을 때만 실제로 움직입니다.
화면이 끊긴 이유는 좌표 갱신 간격이었다
비행기 움직임이 뚝뚝 끊겨 보인다는 게 속도를 고치고 나니 눈에 들어왔습니다.
컨트롤러는 20밀리초마다, 그러니까 초당 50번 비행기를 움직입니다. 그런데 화면이 그 좌표를 받아오는 건 훨씬 뜸했습니다. 화면은 초당 60번 그리는데 위치는 초당 20번쯤만 갱신되니, 같은 자리에 세 번 그리고 갑자기 점프하는 식이었습니다.
두 갈래로 손봤습니다. 통신 주기를 당겨서 좌표를 더 자주 받아오게 하고, 그래도 남는 간격은 화면 쪽에서 부드럽게 이어 그리도록 했습니다. 재보니 좌표가 32밀리초마다 7픽셀씩 점프하는데, 그 7픽셀을 프레임 사이에 나눠 그리는 것입니다.
여기서 함정을 하나 미리 막았습니다. 적이 차지하는 자리는 재사용됩니다. 이어 그리기를 그대로 적용하면 새로 나타난 적이 방금 죽은 적의 자리에서 날아오는 것처럼 보입니다. 자리의 주인이 바뀌면 이어 그리지 않고 즉시 제자리에 찍도록 예외를 뒀습니다.
난이도별 적 속도와 점수 배율
난이도는 속도를 정하고 나니 “이건 사람마다 다르겠다” 싶어 붙였습니다. 설정 화면에는 이미 맥주 게임용 난이도(쉬움·보통·어려움)가 있었습니다. 새로 만들 것 없이 그걸 슈팅 속도에 연결했습니다. 아케이드 기계에 난이도 스위치가 하나인 게 자연스럽기도 하고요.

실기에서 잰 값입니다. 손으로 정한 “보통”을 기준선에 두고 위아래로 벌렸습니다.
점수도 난이도에 비례하게 했습니다. 어려운 쪽에서 얻은 기록이 순위표에서 정당하게 높아야 하니까요. 여기서 정수 나눗셈에 한 번 걸렸습니다. 생존 점수가 1초에 1점이었는데, 쉬움 난이도의 70%를 곱하면 소수점이 잘려 0점이 됩니다. 생존 점수가 통째로 사라지는 것입니다. 어려움에서도 1점은 배율을 곱해봐야 그대로였습니다. 단위를 1초에 10점으로 올려서 세 난이도 모두 배율이 제대로 걸리게 했습니다.
재밌는 건 실측 결과입니다. 22초 동안 벌어들인 점수가 쉬움에서 오히려 더 높게 나왔습니다. 배율은 분명히 낮은데도요. 이유는 간단합니다 — 적이 느리면 화면에 오래 머무니까 격추 기회가 더 많습니다. 속도를 낮추는 것 자체가 점수를 올리는 방향으로 작용합니다. 순위표를 난이도와 무관하게 공정하게 만들려면 배율을 훨씬 더 벌리거나 아예 표를 나눠야 한다는 뜻인데, 이건 아직 안 고쳤습니다.
빌드는 통과하는데 실행해야 드러나는 버그들
이번 편에서 유독 많았던 게, 컴파일도 빌드도 멀쩡히 통과하는데 실제로 돌려봐야만 드러나는 버그들이었습니다. 몇 개를 추립니다.
| 증상 | 원인 |
|---|---|
| 등장하자마자 사라지는 적 | 등장 y좌표를 음수로 잡아, 음수를 못 담는 메모리가 즉시 지워버림 |
| 22분쯤 놀면 게임이 얼어붙음 | 누적 타이머가 넘칠 때를 고려하지 않음 |
| 지그재그 적이 직진형과 똑같음 | 좌우 주기를 너무 짧게 잡아 이동이 아니라 떨림이 됨 |
| 비행기 하단이 화면 밖으로 잘림 | 플레이 영역 높이를 24px 크게 잡음 |
| 통신이 예상만큼 안 빨라짐 | 통신 속도를 두 배로 낙관했는데 라이브러리 구조상 불가능한 값이었음 |
| 종료 버튼을 눌러도 안 나가짐 | 게임 상태 읽기를 빠뜨려 슈팅 화면에서 못 빠져나옴 |
공통점은 정적으로는 안 잡힌다는 것입니다. 코드를 아무리 다시 읽어도, 실제로 만들어 돌려보거나 라이브러리 소스를 파고들어야 드러납니다. 특히 마지막 종료 버그는 배포하고 나서야 “어? 왜 안 나가지” 하고 만났을 문제였습니다. 그래서 이 프로젝트는 매 단계 — 빌드가 아니라 실행을 완료의 기준으로 삼습니다.
아직 남은 것 — 폭탄 버튼 배선
폭탄 버튼이 안 됩니다. 조이스틱에 달린 스위치를 눌러 화면의 적을 쓸어버리는 기능인데, 배선에서 신호가 안 들어옵니다. 로직은 멀쩡합니다 — 폭탄 개수가 줄어드는 것까지는 확인했습니다.
검증하려다 한 번 더 배운 게 있습니다. 스위치가 눌린 상태를 소프트웨어로 흉내내려고 값을 강제로 고정했는데, 하필 그 값은 프로그램이 매 순간 다시 계산하는 값이었습니다. 강제값과 계산이 서로 덮어쓰면서 값이 진동했고, “눌렀다 뗐다”가 초당 수십 번 일어난 셈이 되어 폭탄 두 개가 한 번에 터졌습니다. 흉내를 내려면 계산된 결과가 아니라 그 재료를 건드려야 했습니다.
슈팅 게임 구현에서 얻은 교훈
- 아낀 자리의 대가가 어디서 나오는지 봐야 합니다. 총알 좌표를 안 보내서 메모리는 아꼈지만 인과가 깨졌습니다. 아끼는 건 좋은데 무엇을 포기하는지는 알고 아껴야 합니다.
- 거부 메시지를 액면 그대로 믿지 마십시오. AI가 “못 한다”고 답할 때 진짜 이유는 화면 구석의 안내문에 있었습니다.
- 부드러움은 프레임 수가 아니라 갱신 간격의 문제입니다. 60fps로 그려도 좌표가 20fps로 오면 20fps로 보입니다.
- 정수 나눗셈은 작은 수에서 조용히 0을 만듭니다. 1점의 70%는 0점입니다.
- 빌드 통과는 완료가 아닙니다. 컴파일도 빌드도 멀쩡한데 돌려봐야만 드러나는 버그가 유독 많았습니다. 완료의 기준은 실행입니다.
메모리를 아끼든 속도를 낮추든, 완료의 기준은 빌드 통과가 아니라 실기에서의 실행입니다.
다음 편
캐비닛 정리 차례라고 생각했는데, 그 전에 같은 부품으로 게임을 하나 더 만들었습니다. 네 번째 게임 — 7부 · 낚시로 “재미없다”를 숫자로 번역하기. 캐비닛 이야기는 그 다음입니다.
🐉 레퍼런스로 삼은 〈드래곤 플라이트〉는 넥스트플로어(현 라인게임즈)가 2012년에 낸 국민 종스크롤 슈팅입니다. 이 글의 기체·아이템 그림과 코드는 전부 오리지널이며, 게임 방식만 참고했습니다.
문의
- Email : [email protected]
- Insta : https://www.instagram.com/going.sen/
- Website : https://intosen.com/kr/consult/
댓글
닉네임만 입력하면 바로 댓글을 남길 수 있어요. Google/GitHub 로그인도 가능합니다.