blog

同じseedの乱数列は2回作ると相関する

  • Claude Code
  • ゲーム開発
  • テスト

マップ生成はseedを1つ受け取って決定的に組み立てる。同じseedなら毎回同じマップになる。デバッグにも再現性にも都合がいい。

このseedから、乱数器が2つ生まれていた。buildWorld(部屋の用途を決める)には呼び出し側のmain.tscreateRng(seed)で作ったものを渡し、startSession(敵の湧き位置を決める)は同じseedを受け取って自分の中でもう1つ作る。作る場所は違うが、種は同じだった。

2つの用途は別々に見える。片方は部屋にどんな設備が置かれるかを決め、もう片方は敵がどこに立つかを決める。呼んでいる場所も違うファイルで、渡している値は同じseedという整数だけだ。当然、互いに無関係な結果になるはずだと思っていた。

「無関係のはず」を実測してみたら外れていた

レビューで、この独立性を実際に確かめてみることになった。同じseedからcreateRng(seed)を2回呼ぶとどうなるか。

中身を見ると、createRngseedをそのまま初期状態にしているだけだった。同じseedを渡せば、2つのインスタンスは中身も同一の生の乱数列を返す。呼び出し場所が違っても、1回目のrng.next()は両方とも同じ値になる。

buildWorldが最初の部屋の用途を決めるのに使う抽選と、startSessionが最初の敵のx座標のジッタを決めるのに使う抽選は、どちらもそのセッションで最初に呼ばれるrngの消費だった。つまり、部屋の用途を決めた値と、敵の位置を決めた値が、同じ乱数を読んでいたことになる。

1000シードで実測すると、用途を1つに固定したときのx座標のジッタ幅が、本来の全幅(2)ではなく、通路なら1/4幅(0.42〜0.44)、保管庫なら1/3幅(0.53〜0.59)の帯に閉じ込まっていた。用途の抽選肢の数だけ、ジッタの取りうる範囲が狭くなっていた。部屋の用途を見れば、敵がだいたいどのあたりにいるかが絞り込めてしまう状態だった。

「構造的に独立」は、推論だけでは分からない

呼んでいる場所が違う、扱っているデータの種類も違う。それだけで独立だと考えるのは、乱数生成器の中身を見ていない推論でしかなかった。実際には同じseedを渡した時点で、2つの乱数器は同じ列を返す同一の関数になっている。用途と座標という別の意味を持つ値でも、その意味を決めているのが同じ生の数列なら、数列の相関がそのまま結果の相関になる。

直し方は、seedとは別にsaltを混ぜて初期状態を変えることだった。

export function createRng(seed: number, salt?: number): Rng {
  let state = (salt === undefined ? seed : mixSeed(seed, salt)) >>> 0;
  ...
}

buildWorld側にだけWORLD_RNG_SALTという別のsaltを渡し、startSession側は従来どおりsalt無しのcreateRng(seed)のままにする。これで2つの乱数器は別の初期状態から始まり、別の列を返すようになる。300シードで測り直すと、各用途のx座標のジッタ幅は1.0を超え(実測1.5〜1.8)、閉じ込められていた帯は消えた。

ただし、ここで確かめたのはあの帯が再発していないことであって、2つの列が統計的に独立であることではない。saltは同じseedから決定的に別の初期状態を作るだけで、独立性そのものを保証する仕組みではない。相関係数も相互情報量も測っていない。回帰試験が守っているのも「幅が1.0より広い」までだ。

saltを2箇所に書いたら、それだけでは終わらなかった

最初の修正では、WORLD_RNG_SALTという値をmain.tsと試験コードのそれぞれにローカルで定義していた。値さえ揃っていれば動くように見える。

ところが、出荷経路であるmain.ts側のsaltを試しに落としてみたところ、テストは全部緑のままだった。試験側が独自にsaltを持っていたので、出荷される実装がsaltを失っても、試験が測っているのは相変わらず「saltがある前提の配置」のままだったからだ。守っているつもりの試験が、実装と違う配置を測っていた。

これを防ぐため、WORLD_RNG_SALTの定義をsim/world.tsの1箇所に集約し、main.tsとバランス測定用の試験の両方が同じ値をimportする形にした。値を2箇所に書くのをやめて、参照する側にする。加えて、main.tsのソースが実際にcreateRng(seed, WORLD_RNG_SALT)という呼び出しを含んでいるかを確認するソース検査も足した。

「構造的に独立のはず」という言葉を、これ以降は信用しないことにした。独立を主張するなら、独立していることを示す実測を先に置く。それができない間は、同じseedを2度使い回さないほうが安全だ。

この乱数で組み上がる部屋と敵の配置はProd Runで遊べる。ただし独立かどうかは、画面を何回見ても分からない。それを言うには実測が要る、というのがこの記事の話だった。

STAGE10/50SCORE0BEST0COMBO×1貯蓄0KILLS0
TAP = FOCUS FIRE
強化ツリー倒した数で買う。1つの強化は1回だけ。枝は左から順に開く。
kill -9 agentKILLS 0
kill -9 agent 停止中