導入
最近、AIが何でも自動でやってくれる話をよく見かけますが、今回の話は真逆です。
乱数を「毎回ちゃんとバラバラに」出すために、自分でハッシュ関数を書いた話です。
地味ですが、地味なだけに知らないとハマる類のものでした。
目的
個人開発しているPWAloki_zenbuには、2048や反応速度ゲームなどのミニゲームが
あります。友人と点数を比べられるように、その日にプレイした人は全員、同じ盤面・
同じ出現順に挑戦するデイリーチャレンジという仕組みを作りました。
このアプリはサーバーを持たない、ブラウザだけで完結する設計です。「今日のお題」を
どこかで計算して配ることができないので、サーバーなしで全員に同じ結果を持たせる
必要がありました。
実装
コンピュータの乱数生成には、同じ「種(シード)」から始めれば常に同じ数列が出る、
という性質があります(疑似乱数生成器、PRNG)。「今日の日付」をシードにすれば、
サーバーなしで全員が同じ盤面にたどり着けるはずだと考えました。
// 端末のローカル時刻から YYYY-MM-DD 相当の整数を作る
function dailySeed(date: Date = new Date()): number {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
return year * 10000 + month * 100 + day;
}
日付の取得はtoISOString()(UTC基準)ではなく、ローカル時刻を使うメソッドに
しています。UTC基準だと、日本時間の夜の時間帯はまだ前日扱いになり、デイリー
チャレンジの切り替わりが体感時刻とズレるためです。
このシードを、シード付き乱数生成器mulberry32にそのまま渡していました。動かして
みて、同じ日なら同じ盤面が出ることも確認できたので、一度はこれで完成としました。
その後、日付を1日ずつずらして何日か分の盤面を見比べたところ、隣り合う日の
最初の数手がやけに似ていることに気づきました。調べると、この手の軽量PRNGには
「内部状態が近いシードを渡すと、最初の数回の出力も近くなりやすい」という性質が
あるとわかりました。20260815と20260816のようにシードの数値がほぼ同じだと、
まさにこれを踏み抜くことになります。
対策として、シードをPRNGに渡す前に、ハッシュ関数で一度撹拌することにしました。
function hashSeed(seed: number): number {
let h = seed >>> 0;
h = Math.imul(h ^ (h >>> 16), 2654435761);
h = Math.imul(h ^ (h >>> 13), 2246822519);
h ^= h >>> 16;
return h >>> 0;
}
これはmurmur3というハッシュアルゴリズムで使われている「アバランチ」という
処理です。入力を1ビット変えるだけで出力が大きく変わるという性質があり、
20260815と20260816のように近い入力でも、ハッシュ後の値はまったくの
別物になります。最終的に「日付 → 整数シード → ハッシュで撹拌 →
mulberry32で乱数列を生成」という3段構成に落ち着きました。
なお、対面で遊ぶチンチロというミニゲームだけは、あえてこのシードを使って
いません。こちらは毎回本当にランダムであってほしいので、Math.random()を
そのまま使っています。
反省・教訓
一度動作確認しただけでは、このクセには気づけませんでした。複数日の盤面を
並べて見比べる、という一手間をかけて初めてわかったことです。「動いたから
完成」で止めず、意図した性質(この場合は日ごとに十分ばらけているか)を
実際に確認する視点は、今後も持っておきたいと思っています。