DEV NOTE · JJ

2.4KB 리플레이 코드와 서버 재검증 리더보드

온라인 리더보드를 만들 때 가장 먼저 걱정되는 것은 조작된 기록입니다. 브라우저가 "클리어 시간 3초"라고 보내면 서버는 그 말을 믿을 수밖에 없는 경우가 많습니다. JJ는 시간 대신 입력 로그를 받고, 게임 시뮬레이션 코드를 그대로 서버에 넣어 그 로그를 다시 재생합니다. 이 글은 리플레이 형식이 어떻게 정해졌고, 같은 코드가 고스트와 리더보드로 어떻게 재사용되는지 정리한 기록입니다.

입력만 있으면 플레이가 재현된다

JJ의 시뮬레이션은 결정적입니다. 1/120초 고정 스텝으로 돌고, 무작위 함수나 플랫폼마다 결과가 다를 수 있는 삼각함수를 쓰지 않습니다. 그래서 한 판의 기록은 매 스텝 두 플레이어가 누른 버튼만 있으면 충분합니다. 한 스텝은 16비트로, 아래 8비트가 EMBER, 위 8비트가 AZURE의 입력입니다. 각 8비트 안에는 왼쪽, 오른쪽, 점프, 당기기, 앵커와 핑 신호가 들어갑니다.

이 로그 하나가 여러 곳에 쓰입니다. 결과 화면의 리플레이 보기, 공유용 리플레이 링크, 다음 판에 함께 달리는 고스트, 회귀 테스트, 그리고 서버 검증입니다. 설계 문서의 원칙대로 "로그 형식은 하나"로 유지한 덕분에 기능을 추가할 때마다 새 저장 형식을 만들 필요가 없었습니다.

리플레이 코드 형식

설계 초안에는 "RLE + base64"라고만 적혀 있었습니다. 실제로 만든 형식은 이렇습니다. 같은 입력이 연속되는 구간을 [하위 바이트, 상위 바이트, 반복 횟수(1~255)] 세 바이트로 묶고, 그 바이트열을 URL에 그대로 넣을 수 있는 base64url 문자로 바꾼 뒤 앞에 형식 버전 J1.을 붙입니다.

// jj/js/replay.js 발췌
function encode(log){
  const bytes=[]; let i=0; while(i<log.length){ const v=log[i]&0xFFFF; let n=1;
    while(i+n<log.length && (log[i+n]&0xFFFF)===v && n<255) n++;
    bytes.push(v&255,v>>8,n); i+=n; }
  return 'J1.'+toB64(bytes);
}

사람은 버튼을 몇십 스텝씩 누르고 있거나 떼고 있기 때문에 이 단순한 압축이 잘 먹힙니다. 3000스텝(25초) 분량이 약 2.4KB가 됩니다. 리플레이 링크는 ?lv=스테이지&r=코드 형식이고, DAILY나 RANDOM처럼 생성된 코스라면 시드와 난이도를 함께 붙입니다. 코스 자체를 보내지 않아도 받는 쪽 브라우저가 같은 시드로 같은 코스를 만들기 때문입니다.

JJ 결과 화면: 별, 시간, 끊김 수와 두 코어의 기여 분해
결과 화면. 이후 버전에서 리플레이 링크·공유 카드·리더보드 등록 버튼이 이 화면에 붙었다.

고스트

고스트는 리플레이를 하나 더 돌리는 것입니다. 스테이지별 최고 기록의 로그를 브라우저 저장소에 코드 형태로 보관하고, 다음 판을 시작하면 별도의 세계를 하나 더 만들어 플레이어와 같은 스텝으로 진행시킵니다. 반투명한 두 코어와 줄이 화면에 함께 그려집니다. 시뮬레이션이 결정적이라 고스트가 기록과 어긋날 일이 없고, 검증할 때도 고스트의 클리어 시각이 저장된 기록과 소수점 둘째 자리까지 같은지만 보면 됩니다. HARDCORE 모드의 기록과 고스트는 따로 보관합니다.

서버가 직접 다시 재생한다

1.3.0에서 DAILY 리더보드를 넣으면서 이 구조의 진짜 이득이 나타났습니다. 브라우저는 날짜, 익명 ID, 닉네임, 리플레이 코드만 보냅니다. 클리어 시간이나 점수는 보내지 않습니다. 서버는 그 날짜의 시드로 DAILY 코스를 직접 생성하고, 받은 로그를 처음부터 재생해 클리어 여부와 시간, 끊김 수, 점수를 스스로 계산합니다.

// worker/verify.mjs 발췌
const L=dailyLevel(date); if(!L) return {ok:false,reason:'level'};
const log=RP.decode(code); if(!log) return {ok:false,reason:'code'};
if(log.length>MAX_STEPS) return {ok:false,reason:'too-long'};
const w=W.createWorld(L); let n=0;
for(const v of log){ W.step(w,v&255,v>>8); n++; if(w.cleared) break; }
if(!w.cleared) return {ok:false,reason:'not-cleared',steps:n};

이게 가능한 이유는 게임 시뮬레이션 파일들이 처음부터 브라우저의 일반 스크립트와 Node 모듈 양쪽으로 로드되도록 작성되어 있었기 때문입니다. 헤드리스 회귀 테스트를 위해 만든 성질이 그대로 서버에서도 쓰였습니다. Worker는 줄, 세계, 레벨, 매크로, 난수, 생성기, 리플레이 파일을 그대로 가져와 번들하며 크기는 76KB입니다. 날짜별 코스는 Worker 메모리에 최대 8개까지 캐시해서, 첫 검증은 111ms, 이후는 15ms 정도 걸립니다. 재생 길이는 최대 5분(36,000스텝)으로 제한했습니다.

한 가지 대가는 점수식이 두 곳에 있다는 점입니다. 게임 쪽 main.js와 서버 쪽 verify.mjs에 같은 scoreOf 함수가 있고, 서버 파일에는 "둘을 같게 유지한다"는 주석을 달았습니다. 물리 수치나 점수식을 바꾸면 서버 검증 결과도 함께 바뀌므로, 저장소 지침에도 이 사실을 명시해 두었습니다.

무엇을 받고 얼마나 보관하나

D1 테이블은 날짜와 익명 ID를 기본 키로 하는 한 줄짜리 구조입니다. 한 사람은 하루에 한 행만 가지며, 더 빠른 시간이 검증되었을 때만 덮어씁니다. 순위는 시간 오름차순, 같으면 먼저 등록한 순서입니다. 입력 검사도 서버에서 합니다.

로컬 Worker와 D1으로 검증할 때 모범 풀이 리플레이를 등록해 1위(24.81초, 1745점)가 되는 것, 같은 기록 재제출 시 "기존 기록 유지", 로그를 절반으로 잘라 보내면 not-cleared로 거부되는 것을 확인했습니다. 서버에 기록이 남기 시작했으므로 개인정보처리방침과 이용약관에 리더보드 항목(선택 등록, 닉네임·익명 ID·리플레이·점수, 60일 보관, 삭제 요청)을 추가했습니다.

정리

결정적 시뮬레이션 하나에서 리플레이, 고스트, 공유 링크, 회귀 테스트, 서버 검증이 모두 나왔습니다. 서버 검증은 "클라이언트를 믿지 않는다"는 원칙을 따르면서도 별도 판정 코드를 만들지 않아도 된다는 점이 가장 컸습니다. 한계도 분명합니다. 리플레이는 사람이 직접 입력했는지까지 보장하지 못하므로, 프로그램으로 만든 완벽한 입력열은 걸러 내지 못합니다. 지금은 서버 재생 검증까지만 두었고, 비정상적인 기록이 실제로 보이기 시작하면 입력 패턴의 이상치 검사를 추가할 생각입니다.