같은 게임 규칙을 두 언어로: AvoidNow의 GDScript 클라이언트와 TypeScript 서버
AvoidNow는 게임 규칙이 Godot(GDScript)과 서버(TypeScript)에 한 벌씩 있다. 두 벌을 어떻게 맞추는지, 서버 두 곳이 한 코드를 어떻게 나눠 쓰는지, 실제로 어긋났던 버그 세 가지를 정리했다.

AvoidNow의 게임 규칙은 두 번 쓰여 있다. 한 벌은 Godot 클라이언트의 GDScript, 한 벌은 서버의 TypeScript다. 같은 걸 두 번 쓰는 건 좋은 설계가 아니다. 그래도 이렇게 된 데에는 이유가 있고, 두 벌이 어긋나지 않게 하는 장치를 몇 개 만들었다. 이 글은 그 구조와, 실제로 어긋났던 경험을 적은 것이다.
왜 두 벌인가
클라이언트는 Godot 4.7로 만들고 웹으로 내보낸다. 웹 내보내기에서 C#은 제약이 많아서 GDScript를 쓴다. 서버는 두 곳이다. 한국 접속자용은 Node.js 서버, 해외 접속자용은 Cloudflare Durable Objects다. Durable Objects는 자바스크립트로 돌기 때문에 서버 쪽 규칙은 TypeScript로 쓰는 게 자연스럽다.
그러면 규칙은 어디에 있어야 할까. 서버가 판정의 주인이니 서버에는 반드시 있어야 한다. 클라이언트에도 있어야 하는 이유는 두 가지다.
- 오프라인 연습: 서버에 못 붙으면 브라우저 안에서 싱글을 돌린다.
- 이동 예측: 온라인에서 내 캐릭터는 서버 응답을 기다리지 않고 먼저 움직인다. 서버와 똑같은 이동 규칙으로 계산해야 나중에 보정할 게 없다.
그래서 규칙은 두 벌이 됐다. 클라이언트 쪽을 먼저 만들었고, 규칙 파일 첫 줄에 “서버로 옮길 때 이 파일이 기준이 된다”고 적어 두었다. 9월 26일 커밋 9a49747에서 이 규칙을 TypeScript로 옮긴 공유 패키지가 생겼다.
규칙은 GDScript와 TypeScript 두 벌. 서버 쪽은 한 벌을 두 서버가 나눠 쓴다.
서버 두 곳, 방 코드는 하나
서버 쪽에서는 중복을 만들지 않았다. 방 하나의 로직(입장, 대기실, 시작, 입력 큐, 틱 진행, 스냅샷, 종료)을 전송 방식과 무관한 클래스 하나에 넣었다. Node 서버와 Durable Object는 WebSocket을 받아 이 클래스에 넘겨주는 얇은 어댑터만 갖고 있다. 협동 부활과 경쟁 밀치기도 공유 코드에만 있다. 한쪽 서버에만 기능을 넣는 일은 규칙으로 금지해 두었다.
클라이언트와 서버의 틱 속도는 다르다. 로컬 싱글은 60Hz로 돌고, 서버는 30Hz로 돌며 15Hz로 스냅샷을 보낸다. 규칙이 시간(초)과 거리(px) 단위로 쓰여 있어서 틱 속도가 달라도 결과가 같다. Godot의 벡터 함수 중 규칙에 쓰는 것(예: move_toward)은 TypeScript에 같은 동작으로 다시 만들었다.
두 벌을 맞추는 장치 세 가지
첫째, 상수에 짝을 적는다. 양쪽 규칙 파일의 상수 옆에 “rules.gd와 같은 값”, “server/shared/rules.ts와 같게” 같은 주석이 붙어 있다. 티라노 돌진의 최소 경고 1초나 꼬리 판정 굵기처럼 나중에 추가한 값도 같은 방식으로 양쪽에 넣었다. 박자(100 BPM)를 바꾸면 양쪽을 같이 바꿔야 한다는 것도 프로젝트 안내 문서에 적혀 있다.
둘째, 같은 테스트 케이스를 양쪽에 둔다. 서버 테스트 이름에 “Godot 테스트와 같은 케이스”라고 적힌 것들이 있다. 예를 들면 (500, 400)에 반지름 60px 낙석이 있을 때, 반지름 16px 발밑 원이 (560, 400)이면 맞고 (580, 400)이면 안 맞는다. 발자국, 충격파, 돌진, 꼬리 판정이 모두 이런 식으로 같은 좌표와 기대값을 가진다. 이동 규칙(가속, 대시, 아레나 경계)도 Godot 예측 코드와 같아야 한다는 테스트가 있다.
셋째, 바이너리 형식은 파일로 비교한다. 스냅샷과 입력을 바이너리로 바꾼 커밋 e6e3d09에서 서버 코드로 만든 스냅샷 한 개를 16진수 파일로 저장해 두고, Godot 테스트가 이 바이트를 풀어 기대한 값이 나오는지 확인한다. 한쪽 형식만 바뀌면 이 테스트가 깨진다. 플레이어 한 명당 48바이트, 입력 한 개는 6바이트다.
그래도 어긋났던 것들
장치가 있어도 어긋났다. 9월 26일 밤에서 27일 새벽 사이에 세 번 있었다.
ack가 받은 입력을 가리켰다 (3538041). 서버는 스냅샷에 “여기까지 처리했다”는 입력 번호(ack)를 붙인다. 클라이언트는 그 뒤의 입력만 다시 적용해 예측한다. 그런데 서버가 적용한 입력이 아니라 받은 입력의 번호를 보내고 있었다. 받기만 하고 아직 움직이지 않은 입력을 클라이언트가 버리니, 스냅샷이 올 때마다 캐릭터가 뒤로 끌려갔다. 입력을 틱마다 정확히 하나씩 적용하고(없으면 기다리고, 밀리면 한 틱에 최대 4개까지 따라잡고), ack는 적용한 번호만 가리키게 고쳤다.
Durable Object가 바이너리 입력을 못 읽었다 (7860504). 해외 서버의 실행 환경은 바이너리 WebSocket 프레임을 Blob으로 넘겨준다. 디코더는 바이트 배열만 기대했다. 해외 서버에서는 플레이어가 전혀 움직이지 않았고 ack는 0에 머물렀다. 공유 코드 테스트는 방 클래스만 검사하고 어댑터는 검사하지 않아서 놓쳤다.
화면 스크립트 문법 오류. Godot 규칙 테스트는 규칙 파일만 불러서, 화면과 UI 스크립트의 문법 오류를 잡지 못했다.
두 번째와 세 번째를 겪고 커밋 73c05a1에서 CI를 늘렸다. 모든 GDScript를 컴파일해 보는 검사를 넣었고, 실제로 띄운 Node 서버와 Durable Object 로컬 서버에 바이너리 입력을 보내 ack가 오르고 캐릭터가 움직이는지 확인하는 E2E 테스트를 넣었다. 커밋 메시지에는 두 검사 모두 예전의 깨진 코드에서 실패하는 걸 확인했다고 적혀 있다.
정리
규칙 두 벌은 여전히 부담이다. 숫자 하나를 바꿀 때 파일 두 개와 테스트 두 곳을 고쳐야 한다. 대신 서버 판정 덕분에 랭킹을 믿을 수 있게 됐고, 그 이야기는 닉네임 랭킹에 썼다. 이 구조로 도는 멀티플레이는 AvoidNow 게임 페이지에서 해 볼 수 있다.


