KKflow

MAKING · 게임 개발

모두가 같은 판을 받는 데일리 챌린지: 시드 난수 이야기

스택 타워의 데일리 챌린지는 날짜를 시드로 써서 모든 플레이어에게 같은 블록 패턴을 줍니다. Math.random 대신 시드 난수를 쓰는 이유와 구현 방법, 주의할 점을 코드와 함께 설명합니다.

· KKflow

스택 타워에는 하루에 한 번 열리는 데일리 챌린지가 있습니다. 이 모드에서는 그날 플레이하는 모든 사람이 똑같은 블록 패턴을 받습니다. 그래야 랭킹이 운이 아니라 실력 경쟁이 됩니다. 이 글은 그걸 가능하게 하는 시드 난수(seeded random)의 원리와 구현을 설명합니다.

Math.random으로는 안 되는 이유

자바스크립트의 Math.random()은 호출할 때마다 예측할 수 없는 값을 줍니다. 일반 모드에서는 이게 장점이지만, 데일리 챌린지에서는 문제입니다. 같은 날 플레이해도 사람마다 블록 속도와 방향이 달라지니까요. 게다가 Math.random()은 시작값(시드)을 지정할 수 없어서 같은 수열을 다시 만들 방법이 없습니다.

필요한 것은 같은 시드를 넣으면 언제 어디서나 같은 수열이 나오는 난수 생성기입니다. 이런 생성기를 의사 난수 생성기(PRNG)라고 부릅니다. 겉보기에는 무작위지만 실제로는 정해진 계산의 결과입니다.

1단계: 날짜를 숫자로 바꾸기

시드로 쓸 값은 "오늘"이어야 합니다. 날짜를 2026-10-02 같은 문자열로 만든 뒤 해시 함수로 32비트 정수로 바꿉니다. 여기서는 간단하고 분포가 고른 FNV-1a 해시를 씁니다.

function hashString(str) {
  let h = 0x811c9dc5;              // FNV-1a 32비트 시작값
  for (let i = 0; i < str.length; i++) {
    h ^= str.charCodeAt(i);
    h = Math.imul(h, 0x01000193);  // FNV 소수를 곱함
  }
  return h >>> 0;                  // 부호 없는 32비트 정수로
}

2단계: 시드로 수열 만들기

시드를 받아 0 이상 1 미만의 수를 차례로 돌려주는 생성기가 필요합니다. 게임에는 짧고 빠른 mulberry32가 자주 쓰입니다.

function mulberry32(seed) {
  let a = seed;
  return function () {
    a = (a + 0x6D2B79F5) | 0;
    let t = Math.imul(a ^ (a >>> 15), a | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const rand = mulberry32(hashString("2026-10-02"));
rand(); // 누가 실행해도 같은 첫 번째 값
rand(); // 누가 실행해도 같은 두 번째 값

이제 Math.random() 자리에 rand()를 쓰면, 같은 날짜에는 모든 기기에서 같은 패턴이 나옵니다. 서버가 패턴을 내려줄 필요도 없습니다.

주의할 점 1: 날짜의 기준 시간대

"오늘"은 사는 곳에 따라 다릅니다. 기기의 현지 시간으로 날짜를 만들면, 시간대가 다른 사람끼리 다른 패턴을 받거나 같은 패턴이 하루 어긋나 열립니다. 데일리 챌린지의 날짜는 하나의 기준 시간대로 고정해야 합니다. 국내 사용자가 대부분이라면 한국 시간 자정을 기준으로 삼는 것이 자연스럽고, 랭킹 서버도 같은 기준으로 날짜를 나눠야 합니다.

주의할 점 2: 난수를 꺼내는 순서

시드 난수는 "몇 번째로 꺼낸 값인가"에 따라 결과가 정해집니다. 그런데 블록 패턴 말고도 파티클 효과나 효과음 변주처럼 난수를 쓰는 곳이 많습니다. 이펙트가 난수를 하나 더 꺼내 가면 그다음 블록의 값이 밀려 버립니다. 플레이어마다 이펙트 발생 횟수가 다르니 결국 패턴이 달라집니다.

해결책은 용도별로 생성기를 나누는 것입니다. 게임 규칙에 영향을 주는 값(블록 속도, 방향, 모디파이어)은 시드 생성기에서만 꺼내고, 연출용 난수는 그냥 Math.random()을 씁니다.

const gameRand = mulberry32(hashString(todayKST()));  // 규칙용: 모두 같음
const fxRand = Math.random;                            // 연출용: 달라도 됨

주의할 점 3: 공정성과 검증

패턴이 모두에게 같다는 것은 공정성의 출발점이지만 전부는 아닙니다. 클라이언트에서 계산한 점수는 조작할 수 있습니다. 랭킹 서버에서는 하루에 낼 수 있는 점수의 상한이나 플레이 시간과 점수의 비율처럼 명백히 비정상적인 기록을 걸러 내는 장치를 함께 두는 편이 좋습니다. 같은 시드라면 입력 기록만으로 게임을 다시 재생할 수 있으므로, 필요하면 리플레이 검증으로 확장할 수도 있습니다.

시드 난수가 쓸모 있는 다른 곳

  • 버그 재현 — 문제가 생긴 판의 시드를 기록해 두면 그 판을 똑같이 다시 만들 수 있습니다.
  • 공유 가능한 맵 — 시드 숫자 하나만 공유하면 친구도 같은 맵에서 플레이할 수 있습니다.
  • 결정론적 결과 — 같은 입력에 같은 결과가 나와야 하는 서비스(예: 날짜와 생년월일로 카드를 섞는 타로)에도 같은 원리가 쓰입니다.

무작위처럼 보이지만 재현할 수 있다는 것, 이것이 시드 난수의 핵심입니다. 게임을 공정하게 만들고 디버깅을 쉽게 해 주니, 랭킹이나 일일 콘텐츠가 있는 게임이라면 처음부터 시드 구조로 설계해 두기를 추천합니다.