flowoodz.
← blog
10분 읽기글 flowoodz

예측한 산탄이 서버와 20~40u씩 어긋났습니다: 매치 난수를 투사체 id 시드로 바꾼 기록

웹 메카 슈팅 RAYFRAME을 온라인으로 붙인 날, 내가 쏜 산탄이 서버 사본과 20~40u씩 벌어졌습니다. 퍼짐을 매치 난수 대신 투사체 id에서 뽑게 바꾼 과정과, 그 한 줄이 밸런스 표와 결정론 해시까지 흔든 이야기입니다.

목차
  1. 예측 투사체를 서버 사본에 맞추는 방식
  2. 원인: 퍼짐 속도를 매치 난수에서 꺼내고 있었습니다
  3. 수정: 퍼짐을 투사체 id에서 뽑았습니다
  4. 같은 커밋에 들어간 -0 문제
  5. 한 줄 바꿨더니 밸런스 표가 흔들렸습니다
  6. 이번에 남긴 규칙

RAYFRAME은 제가 만들고 있는 웹용 3대3 측면 시점 메카 슈팅입니다. 2026년 10월 9일에 오프라인 코어 위에 온라인을 붙였습니다.

서버는 30Hz로 시뮬레이션을 돌리는 권위 서버이고, 클라이언트는 내 입력을 먼저 반영해 화면에 보여 준 뒤 서버 결과가 오면 맞춰 가는 예측 방식입니다.

넷코드 본체 커밋이 15:22에 들어갔고, 41분 뒤인 16:03에 이 글의 수정이 들어갔습니다. 그사이에 본 증상은 이것 하나였습니다. 내가 쏜 산탄이 서버 사본이 도착할 때마다 20~40u씩 옆으로 끌려갔습니다.

예측 투사체를 서버 사본에 맞추는 방식#

이 게임에서 투사체 id는 아무 숫자가 아닙니다. (입력 seq × 16 + 슬롯) × 64 + 순번으로 정해집니다. 클라이언트도 내 입력의 seq를 알기 때문에, 서버가 그 탄에 붙일 id를 미리 알 수 있습니다.

그래서 클라이언트는 내 탄을 먼저 만들어 날리고, 서버 사본이 오면 같은 id를 찾아 위치를 서버 쪽으로 100ms에 걸쳐 옮깁니다.

이 과정에서 예측 위치와 서버 위치의 거리를 adoptOffsets에 계속 기록해 두었습니다. 거리가 0에 가까우면 예측이 맞은 것이고, 클수록 화면에서 탄이 눈에 띄게 미끄러집니다. 120u를 넘으면 미끄러뜨리지 않고 바로 순간이동시킵니다.

오프셋이 20~40u로 찍힌 것은 한 번에 여러 발이 나가는 산탄(volley)과 궁극기 미사일 폭풍이었습니다. 둘 다 발마다 속도를 다르게 주는 무기입니다.

원인: 퍼짐 속도를 매치 난수에서 꺼내고 있었습니다#

산탄은 발마다 속도를 조금씩 다르게 줍니다. 원래 코드는 이랬습니다.

const speed = n > 1 ? L.speed * (0.9 + 0.2 * rand(s)) : L.speed;

rand(s)는 매치 전체가 공유하는 난수 상태 state.rng에서 다음 값을 꺼냅니다. sim 패키지의 불변 조건에도 "난수는 state.rng"라고 적어 두었고, 오프라인에서는 이게 정답이었습니다.

같은 시드와 같은 입력이면 Node에서 돌리든 브라우저에서 돌리든 같은 경기가 나와야 했으니까요.

온라인에서는 사정이 다릅니다. 서버의 state.rng는 경기에 나온 6기가 정해진 순서로 꺼내 쓰면서 진행됩니다. 클라이언트의 예측 월드는 스냅샷마다 새로 만들고, 남의 투사체나 능력은 넣지 않습니다.

스냅샷에는 난수 상태도 실려 오지 않습니다. 넷코드 패키지 소스에서 rng를 검색하면 한 건도 나오지 않습니다.

결국 클라이언트가 내 산탄을 예측할 때 꺼낸 난수는 서버가 같은 탄에 쓴 난수와 아무 관계가 없었습니다. 각도는 고정이고 속도만 ±10% 흔들리니, 날아갈수록 앞뒤로 벌어진 겁니다.

매치 난수를 쓰면 서버와 클라이언트가 서로 다른 값을 꺼내고, 투사체 id를 시드로 쓰면 양쪽이 같은 값을 얻는 구조 비교

수정: 퍼짐을 투사체 id에서 뽑았습니다#

양쪽이 이미 똑같이 아는 값은 그 탄의 id였습니다. id를 시드로 난수를 한 번 만들면 서버와 클라이언트가 같은 값을 얻습니다.

/** Per-projectile spread in [0, 1) from its id, not the match RNG: the shooter's client predicts the same volley. */
const idJitter = (id: number) => rngFloat(seedRng(id));
 
const speed = n > 1 ? L.speed * (0.9 + 0.2 * idJitter(peekId(m))) : L.speed;

peekId는 다음에 생성될 탄의 id를 미리 계산만 하는 함수입니다. 실제 생성 함수 nextId는 순번을 하나 올리기 때문에, 속도를 계산하는 자리에서 그걸 부르면 id가 한 칸씩 밀립니다.

미사일 폭풍은 id를 먼저 받아 두고 그 값으로 속도와 투사체를 같이 만들도록 순서를 바꿨습니다.

지금 프로젝트 문서에 남아 있는 자기 투사체 오프셋 실측 중앙값은 0.2u입니다. 20~40u와 비교하면 사실상 제자리에서 맞춰지는 셈입니다.

같은 커밋에 들어간 -0 문제#

같은 커밋에서 고친 것이 하나 더 있습니다. 입력 로그를 JSON으로 저장했다가 다시 재생하면 해시가 원래 경기와 맞지 않았습니다.

결정론 해시는 상태의 숫자를 DataView.setFloat64로 8바이트로 풀어서 섞습니다. 여기서 -0과 0은 첫 바이트가 다릅니다(0x80과 0x00). 그런데 JSON은 -0을 0으로 씁니다.

오늘 Node로 다시 확인해 봤습니다.

JSON.stringify({ x: -0 })              // '{"x":0}'
Object.is(JSON.parse(JSON.stringify(-0)), -0)  // false
-0 === 0                                // true

===로는 같은 값인데 바이트로는 다른 값이라서, 로그를 한 번 왕복시키면 해시가 달라졌습니다. 해시 함수에서 0이면 양수 0으로 바꿔 넣게 한 줄을 고쳤습니다.

dv.setFloat64(0, n === 0 ? 0 : n); // -0 and 0 are the same value (a JSON log stores -0 as 0)

그 뒤 5,899틱짜리 기록 로그의 해시가 Node와 브라우저에서 같은 36cd0fa8로 나왔습니다.

한 줄 바꿨더니 밸런스 표가 흔들렸습니다#

여기서 끝날 줄 알았습니다. 그런데 퍼짐 값의 출처를 바꾸면 봇끼리 붙는 모든 경기의 궤적이 달라집니다.

9기체 승률을 600판 봇 대전으로 재는 밸런스 시뮬레이션을 다시 돌리자 TETHER가 56.5%, BREAKER가 44.1%로 나왔습니다. 목표 범위가 44~56%였으니 하나는 넘고 하나는 경계에 붙은 상태입니다.

15분 동안 두 번 조정했습니다.

회차 바꾼 값
7 TETHER 산성 DPS 560 → 540, BREAKER 산탄 680 → 700
8 BREAKER 산탄 700 → 740

8회차 뒤 9기체가 모두 44.6~54.3% 안에 들어왔고, 결정론 해시는 4a52ab12로 바뀌었습니다. 16:18 커밋입니다.

다만 이건 푼 문제라기보다 알게 된 성질에 가깝습니다. 기체별 표본 오차가 약 ±3%p이고, sim을 건드릴 때마다 궤적이 섞이면서 기체별 승률이 ±5%p씩 흔들립니다.

이번 BREAKER 조정도 일부는 실제 성능 변화가 아니라 이 흔들림을 따라간 것일 수 있습니다. 표본을 늘려서 둘을 가르는 일은 아직 하지 않았습니다. 미결입니다.

이번에 남긴 규칙#

  • 예측하는 쪽이 재현해야 하는 무작위 값은 공유 난수 흐름에서 꺼내면 안 됩니다. 양쪽이 이미 똑같이 아는 값(여기서는 투사체 id)에서 만들어야 합니다.
  • 결정론 해시를 바이트로 만든다면 -0을 먼저 정리해야 합니다. JSON을 한 번 거치면 부호가 사라집니다.
  • 시뮬레이션 코드의 난수 출처를 바꾸면, 의도한 변경이 아니어도 밸런스 측정을 다시 해야 합니다. 이번에는 그게 튜닝 두 회차였습니다.

sim 패키지의 불변 조건 "난수는 state.rng" 옆에는 지금 이런 줄이 하나 더 붙어 있습니다. "산탄·미사일 폭풍 속도 퍼짐은 투사체 id에서 나온다. 자기 투사체를 클라이언트가 같은 값으로 예측하기 위해서다."