서버는 게임을 모른다: Durable Objects 중계와 지연 락스텝
JJ 1.1.0에서 방 코드로 멀리 있는 친구와 함께하는 온라인 협동을 넣었습니다. 서버는 코어 위치도, 줄 장력도 모릅니다. 두 브라우저가 서로의 버튼 입력만 주고받고 각자 같은 시뮬레이션을 돌립니다. 이 구조가 가능했던 이유는 첫 버전부터 시뮬레이션을 결정적으로 만들어 두었기 때문이고, 막상 붙여 보니 어려운 부분은 네트워크가 아니라 "명령을 어느 프레임에 적용하느냐"였습니다.
온라인을 처음부터 전제로 깔아 둔 이유
설계 단계에서 온라인은 2차 로드맵이었지만, 입력 계층은 처음부터 온라인을 가정하고 만들었습니다. 시뮬레이션은 장치를 전혀 모르고 슬롯별로 8비트짜리 입력만 받습니다. 왼쪽, 오른쪽, 점프, 당기기, 앵커가 각각 한 비트이고 핑은 나머지 비트에 들어갑니다. 키보드 왼쪽, 키보드 오른쪽, 게임패드, 터치 반쪽, AI, 리플레이가 모두 같은 인터페이스로 이 비트를 만들어 냅니다. 로비에는 v1부터 잠긴 ONLINE 카드와 닉네임 입력란을 두었고, 로컬 플레이도 0~8스텝의 입력 지연 버퍼를 거치게 했습니다. 덕분에 1.1.0에서 한 일은 "원격 입력원"을 하나 더 붙이는 것에 가까웠습니다.
시뮬레이션은 1/120초 고정 스텝이고, Math.random과 삼각함수를 쓰지 않으며, 난수는 정수 연산 시드 생성기에서만 나옵니다. 같은 레벨에 같은 입력열을 같은 프레임에 넣으면 어느 기기든 같은 세계가 됩니다. 처음 만든 날 Node와 브라우저에서 같은 레벨을 돌려 해시가 일치하는 것을 확인했고, 이후 모든 버전에서 회귀 테스트가 이 해시를 비교합니다.
Durable Object 하나가 방 하나
서버는 Cloudflare Workers와 Durable Objects로 만들었습니다. 사이트가 이미 Cloudflare Workers의 정적 에셋으로 배포되고 있었기 때문에 별도 서버가 필요 없었습니다. 방 코드는 혼동하기 쉬운 글자(I, O, 0, 1)를 뺀 32종 문자 중 4글자이고, 코드 이름으로 Durable Object를 찾으므로 같은 코드를 입력한 두 사람은 같은 객체에 연결됩니다. 방에는 최대 두 명이 들어가며 먼저 온 쪽이 호스트(EMBER), 다음이 게스트(AZURE)입니다.
방 객체가 하는 일은 메시지 중계뿐입니다. 입력 묶음(in)과 해시(hash)는 상대에게만 보내고, 게임 시작(start)과 명령(cmd)은 보낸 사람까지 포함한 양쪽에 같은 순서로 돌려보냅니다. 메시지 길이는 4096자로 제한하고, 방은 최대 2시간 뒤 알람으로 정리됩니다. 닉네임과 입력은 소켓에 붙은 메모리에만 있고 저장소에는 아무것도 쓰지 않습니다. 하이버네이션 WebSocket API를 써서 대기 중인 방이 자원을 붙잡지 않게 했습니다. 설계 문서에 대안으로 적어 둔 PartyServer나 P2P 시그널링은 필요 없었고, 방 클래스는 60줄 정도로 끝났습니다.
지연 기반 락스텝
클라이언트는 프레임 f에서 읽은 내 입력을 f + D 프레임에 예약합니다. 그리고 두 사람의 입력이 모두 도착한 프레임만 진행하고, 하나라도 없으면 그 자리에서 멈춥니다. D만큼의 지연이 네트워크 왕복 시간을 덮어 주는 동안에는 멈춤 없이 진행됩니다.
// jj/js/net.js 발췌
push(f,bits){ const tf=f+D; if(inp[me][tf]!=null) return; inp[me][tf]=bits; if(outF<0) outF=tf; outQ.push(bits); },
ready(f){ const ok = inp[0][f]!=null && inp[1][f]!=null; if(ok) stallSince=0; else if(!stallSince) stallSince=performance.now(); return ok; },
flush(){ if(outQ.length){ send({t:'in',f:outF,b:outQ,g:gid}); lastSentF=outF+outQ.length-1; outQ=[]; outF=-1; } },
시뮬레이션은 초당 120스텝이지만 입력은 화면 갱신마다 한 묶음으로 보내서 초당 약 60개 메시지가 오갑니다. 메시지마다 게임 번호를 붙여, 재시작 직후 늦게 도착한 지난 판의 입력이 새 판에 섞이지 않게 했습니다. 기본 지연은 5스텝, 약 42ms입니다.
배포하고 측정해 보니 개발 PC에서 WebSocket 왕복 시간이 약 930ms로 나왔습니다. 정적 파일의 첫 바이트 응답도 2초가 걸려서 회선이나 경로 문제로 보였지만, 이런 환경이 실제로 있다는 뜻이기도 했습니다. 지연이 왕복 시간보다 짧으면 매 프레임 멈춥니다. 그래서 호스트가 게임을 시작할 때 설정값과 측정 왕복 시간에서 계산한 값 중 큰 쪽을 지연으로 정해 양쪽에 함께 보냅니다. 계산식은 왕복 시간 × 0.55를 스텝 수로 바꾼 뒤 2스텝을 더하는 것이고, 상한은 60스텝(0.5초)입니다. 현재 지연 스텝은 HUD에 표시됩니다.

디싱크는 명령에서 생겼다
두 브라우저는 120프레임(1초)마다 월드 해시를 계산해 교환합니다. 해시는 두 코어 좌표, 줄 길이, SYNC 값을 정수로 반올림해 섞은 값입니다. 서로 다르면 "디싱크 감지" 경고를 한 번 띄우고 콘솔에 프레임 번호를 남깁니다.
개발 중 실제로 디싱크가 난 곳은 물리 계산이 아니었습니다. 호스트가 챔버 재시작을 누르면 처음에는 호스트 화면에서 바로 재시작하고 게스트에게 알렸습니다. 그러면 호스트는 버튼을 누른 프레임에서, 게스트는 메시지를 받은 몇 프레임 뒤에서 재시작하게 되어 적용 시점이 어긋났습니다. 그래서 규칙을 바꿨습니다. 시작과 명령은 서버가 보낸 쪽까지 포함해 양쪽에 같은 순서로 돌려보내고, 명령에는 적용할 프레임 at을 붙입니다. 재시작은 현재 프레임에 지연의 두 배와 12스텝을 더한 미래 프레임에 예약되고, 양쪽 모두 그 프레임에 도달했을 때 적용합니다. 1.4.0에서 추가한 줄 풀기(엉킴 해제)도 같은 방식으로 명령을 거쳐 같은 프레임에 실행됩니다.
상대가 나가면 어떻게 할지도 정해야 했습니다. 상대 입력을 0.7초 넘게 기다리면 자막으로 알리고, 상대가 연결을 끊은 뒤 10초가 지나면 그 코어의 입력을 SOLO 모드의 AI 파트너로 채워 넣어 계속 진행합니다. 온라인에서는 코어 바꾸기와 SWAP 모드를 끕니다. 두 사람의 슬롯이 고정되어야 입력열이 일치하기 때문입니다.
로컬 검증 환경
처음에는 정적 에셋까지 포함한 개발 서버로 검증하려 했는데, 에셋 폴더를 감시하던 개발 서버가 자동 테스트 도구의 로그 파일이 생길 때마다 재시작되면서 Durable Object와 소켓이 통째로 날아갔습니다. 그래서 에셋 없이 Worker와 Durable Object만 띄우는 별도 설정을 만들고, 게임은 일반 정적 서버에서 ?api= 파라미터로 그 Worker를 가리키게 했습니다. 이 환경에서 두 탭을 호스트와 게스트로 붙여 1-1 스테이지를 진행했을 때 초당 120프레임 진행, 멈춤 0회, 왕복 2~3ms, 양쪽 최종 해시 동일, 디싱크 0건을 확인했습니다. 게스트를 내보내면 호스트 쪽이 10초 뒤 AI 파트너로 전환되는 것도 확인했습니다.
정리
결정적 시뮬레이션 위에 지연 락스텝을 얹으면 서버는 아무것도 몰라도 됩니다. 저장할 것도 검증할 것도 없으니 개인정보처리방침에도 "중계만 하고 저장하지 않는다"고 적을 수 있었습니다. 남은 숙제는 실제 기기입니다. 설계 문서의 완료 기준인 "두 휴대폰으로 DAILY 완주, 디싱크 0건"은 아직 실기기 측정을 기다리고 있고, 원거리 회선에서 자동 지연이 체감상 견딜 만한지도 플레이 기록으로 확인해야 합니다.