JavaScriptで毎日同じゲーム問題を作る
seed・Fisher–Yates・version・timezoneの設計
なぜ「決定的」なのか
『60秒後の彼女』のDAILY RESCUEは、日付が同じなら誰でも同じチャレンジになる。サーバーやログインは使わない。これを「ランダム」ではなく「決定的(deterministic)」にする理由は、友人同士で「今日のチャレンジ」を共有し、同じ条件で競えるようにするためだ。
決定的な生成の核心は3つ:(1)日付から作るシード文字列、(2)そのシードで動く擬似乱数生成器(PRNG)、(3)PRNGに依存しない順列(Fisher–Yates)。これらが揃えば、Node.jsでもブラウザ(Chromium)でも同じ結果が出る。
randomとdeterministicの違い
Math.random() は「非決定的」だ。同じコードを2回走らせても別の値になる。日付でチャレンジを固定したい場合、シード可能なPRNGが必要になる。
本作が採用したのは FNV-1a による文字列→32bitハッシュと、mulberry32 というシード可能PRNGだ。どちらも暗号用途ではない(後述)が、再現性には十分。
function hashSeed(str) {
let h = 0x811c9dc5; // FNV-1a 32-bit offset basis
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193); // FNV prime
}
return h >>> 0;
}
function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
シード文字列は 60sec-daily|p2|2026-07-20 のように「プレフィックス + protocol version + 日付」で作る。versionを含めることで、後方互換を保ちつつ生成ロジックを変えられる(後述「protocol version」)。
避けるべき:random comparator sort
「日替わりの順番」を作りたいとき、次の書き方はバグの温床になる:
// ❌ 非uniformかつengine依存
const cueOrder = [1, 2, 3, 4, 5].sort(() => rng() - 0.5);
Array.prototype.sort の比較関数は「a が b より小さいとき負の数」という契約を持つ。() => rng() - 0.5 はこの契約を満たさず、JSエンジンのソート実装(Timsortやクイックソート等)によって順列の分布が変わる。あるブラウザでは偏り、別のブラウザでは別の偏りが出る。決定的チャレンジには致命的だ。
代わりに Fisher–Yates を使う。これは「各位置について、未処理の範囲から一様に一つ選んで交換する」アルゴリズムで、PRNGが決まれば順列も一意に決まる。
export function shuffleSeeded(items, rng) {
const out = [...items];
for (let i = out.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
本作の単体テストでは、固定シード mulberry32(42) で shuffleSeeded([1,2,3,4,5]) の出力がNode/Chromium両方で一致することを検証している。
厳密な日付検証
日付文字列をそのまま new Date(str) に渡すと危険だ。2026-02-31 は「3月3日」に読み替えられる(rollover)。本作は正規表現で厳密に検証し、さらに構築した日付が入力と一致するかチェックする:
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
export function parseDateStrict(str) {
if (typeof str !== 'string') return null;
const m = DATE_RE.exec(str);
if (!m) return null;
const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
if (mo < 1 || mo > 12) return null;
if (d < 1 || d > 31) return null;
const dt = new Date(y, mo - 1, d);
if (dt.getFullYear() !== y || dt.getMonth() + 1 !== mo || dt.getDate() !== d) return null;
return { y, m: mo, d };
}
これで 2026-02-29(非うるう年)や 2026-02-31、空白を含む値、タイムスタンプはすべて却下される。単体テストで各ケースを確認済み。
タイムゾーンの方針
「同じ日」をどう定義するかは仕様である。選択肢は3つ:
- A. プレイヤー端末のローカル日付(採用)
- B. JST固定
- C. UTC固定
本作はプレイヤー端末のローカル日付を採用した。理由は「日本中心のユーザーが多い」「友人と共有コードで遊ぶ」「海外からのアクセスでもその人の『今日』で遊べる」の3点を両立するため。仕様として timezonePolicy: 'player-local' をスナップショットに持たせ、日付の根拠を明示している。ただし「世界中で同時に同じチャレンジ」ではない点は意図的な設計判断だ。
protocol version と challenge code
生成ロジックを将来変える場合、古い共有コードを新ロジックで解釈すると矛盾が起きる。そこで challenge には protocol version を埋め込む。フォーマット例:
60S-D20260720-V2-1A2B3C4D-CHECK
D20260720:日付V2:protocol version(多桁対応。V10以上も扱える)1A2B3C4D:シードの16進フィンガープリントCHECK:日付+version+seed からのチェックサム(typo検出用、暗号学的ではない)
パース時は「バージョンが実装済みの上限を超える」場合は拒否する(将来バージョンの生成ロジックが未実装なので、再現できないから)。チェックサムで転記ミスを弾く。これらは単体テストで確認済み。
cross-runtime fixture
「同じ入力なら同じチャレンジ」を証明するため、固定日付・固定versionのスナップショットをテストで保持する:
const fixture = computeDailyChallenge('2026-07-20', 2);
// fixture.cueOrder, fixture.beatSequence, fixture.targetJitter は
// Node 24 と Chromium で一致する(Fisher–Yates + シード済みPRNGのため)
実測では、2026-07-20 を指定したときの cueOrder・beatSequence・targetJitter が、Vitest(Node)とPlaywright(Chromium)の両方で同一であった。
fairness 制約
決定的に生成したあと、不可能なチャレンジになっていないか検証する。本作は validateChallenge で次をチェックする:
- targetJitter が安全範囲(±10%)内か
- cueOrder が1..5の順列か
- beatSequence が6要素か
- 同一の5フェーズであり、隠れたランダムペナルティがないか
これに違反するスナップショットは生成時に例外とする。つまり「運悪くクリア不可」が起きない。
storage migration と privacy
ローカル記録(streak・daily best)は localStorage のみ。スキーマバージョンを分離し、unknown schema や壊れたJSONでもクラッシュせずデフォルトに戻る。詳細はローカル記録の記事を参照。外部送信は一切行わない。
制限(limitations)
- PRNG(mulberry32)は暗号用途ではない。チャレンジの予測困難性を高いセキュリティで保証するものではない。
- タイムゾーンはプレイヤー端末依存。世界中で同日同チャレンジではない。
- チェックサムはtypo検出用で、改ざん検出(改竄防止)のためのものではない。
- ブラウザ実装の浮動小数点挙動に依存するが、Fisher–Yatesと整数演算主体のため実装上の差は観測されていない。