本文へスキップ

決定的なブラウザゲーム:同じ入力なら同じゲーム ​

決定的なゲームは、同じ入力を与えれば毎回同じ結果になります。この性質ひとつで、リプレイ、再現できるバグ報告、速いロジックテスト、ファジング、ロックステップのマルチプレイが手に入ります。JavaScript では、Math.random()、Date.now()、ブラウザのタイマー、モニターによって変わるフレームのデルタを通じて、これが静かに壊れます。直し方は固定ステップのループとシード付きの乱数生成器です。よくある原因は、何も実行しないうちに game-lint で見つけられます。

壊すもの代わりに使うもの
Math.random()、crypto.randomUUID()シード付き PRNG。シードは記録と一緒に保存
ロジック内の Date.now()、performance.now()ゲーム自身のクロック:ティック数かシミュレーション上の秒数
ロジック内の setTimeout、setInterval、requestAnimationFrame のタイミングエンジンのタイマー、またはステップを数える
フレームごとに変わる dtアキュムレータを使う固定タイムステップ
アセットやネットワークの応答が届く順番の違い読み込みが終わってからシミュレーションを始める
キーの形が変わるオブジェクトの反復(まれ)配列で反復する
CPU やブラウザごとの浮動小数点(マシン間のみ)どこでも同じビルド、ロックステップなら整数や固定小数点の計算

決定性で得られるもの ​

  • リプレイ。 シミュレーションがシードと入力だけで決まるなら、リプレイファイルはその 2 つだけです。大きさは数 KB です。
  • 再現できるバグ報告。「2 つ目のドアの近くで床をすり抜けた」がファイルになります。誰が再生しても同じフレームにたどり着きます。
  • ロジックテスト。 キーを押し、決まったステップ数だけ待ち、状態を確かめる。ブラウザもタイミング任せの不安定さもいりません。
  • ファジング。 ランダムな入力で、プレイヤーが踏むクラッシュや操作不能を見つけます。入力にもシードがあれば、見つかったものはどれも繰り返し実行できます。
  • ロックステップのマルチプレイ。 各ピアは入力だけを送り、全マシンが同じゲームをシミュレーションします。実行をまたぐだけでなくマシンをまたいで成り立つ必要があるので、いちばん要求が厳しいものです。

JavaScript ゲームで決定性を壊すもの ​

シードのない乱数 ​

Math.random() にはシードを渡せないので、同じ入力の 2 回の実行でも違う数字が出ます。これを読む出現位置、クリティカル判定、AI の選択のひとつひとつがリプレイをずらします。エンジンのヘルパーが内部でこれを呼んでいることも多いので、使っているものの中身を確かめてください。

実時間の時計を読む ​

Date.now() - lastShot > 300 と書いたクールダウンは、マシンの速さやタブがバックグラウンドにあったかどうかに左右されます。実時間より速く再生したり、ブレークポイントで止めたりすると、クールダウンの終わるステップが変わります。

ゲームロジック内のブラウザタイマー ​

setTimeout(spawnBoss, 5000) は実時間で、ループの外で発火します。ゲームを一時停止しても動き続け、実時間より速く進むリプレイでは違うフレームで発火します。ループを回すのに requestAnimationFrame を使うのは問題ありませんが、そのコールバック回数を数えるロジックはディスプレイのリフレッシュレートに依存します。

変わるフレームデルタ ​

実際の dt で x += speed * dt とすると、60 Hz と 144 Hz で軌道がわずかに変わり、カクつきの後ではまた変わります。目に見えるのは当たり判定です。速い物体が、長いフレームでは薄い壁をすり抜け、短いフレームでは当たります。

非同期の読み込み順 ​

アセットや fetch が違う順番で終わり、それをシミュレーションの途中で読むと、プレイヤーが何も触らないうちに 2 回の実行が分かれます。先に読み込み、それからクロックを動かしましょう。

反復順(まれ) ​

JavaScript のオブジェクトのキーは挿入順を保ちます(整数のようなキーは先に昇順で並びます)。なのでたいていは問題になりません。困るのは、非同期コールバックから埋める Set のように、挿入の順番そのものが決まっていないときです。

マシンをまたぐ浮動小数点 ​

同じマシンの同じビルドなら、同じ浮動小数点演算は同じビットを返します。ブラウザや CPU をまたぐと、Math.sin や Math.exp などは同一の結果を返すことが求められていません。そのため、全プレイヤーでビット単位まで一致させる必要があるロックステップのゲームは、シミュレーションに整数や固定小数点の計算をよく使います。自分のマシンでのリプレイやテストには必要ありません。

決定性を保つには ​

1. アキュムレータを使う固定タイムステップ ​

シミュレーションはきっちり同じ幅のステップで進め、描画はその間を補間します。実際の経過時間は、何ステップ進めるかを決めるだけです。

ts
const STEP = 1 / 60;
const MAX_FRAME = 0.25; // don't spiral after a tab switch
let acc = 0;
let last = performance.now(); // the loop may read real time; the simulation never does

function frame(now: number) {
  acc += Math.min((now - last) / 1000, MAX_FRAME);
  last = now;
  while (acc >= STEP) {
    update(STEP); // always the same dt
    acc -= STEP;
  }
  render(acc / STEP); // alpha for interpolation
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

このループの定番の解説は Glenn Fiedler の Fix Your Timestep! です。

2. 記録と一緒に保存するシード付き生成器 ​

mulberry32 は数行で書けて、ゲームプレイには十分です。

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

const seed = 1234; // save this with the replay
const random = mulberry32(seed);

シードはセッションごとに一度だけ与え、入力と一緒に保存します。巻き戻しが必要なら、生成器の状態もスナップショットに入れてください。Phaser には組み込みのものがあります。Phaser.Math.RND はシード付きの RandomDataGenerator で、Phaser.Math.RND.sow([seed]) でシードを設定し直せます。

3. ゲームのクロックとエンジンのタイマー ​

クールダウンや出現は、ステップ数か、update の中でだけ進むシミュレーション上の秒数で測ります。エンジンのタイマーがゲームループで動くならそれを使い、そうでなければステップを数えましょう。

4. テストでは一定のデルタ ​

テストでは実時間を待たずに、自分で update(STEP) を呼んでゲームを進めます。600 ステップ回すテストは、CPU にどれだけ時間がかかっても 10 秒分のプレイをシミュレーションします。

確かめ方 ​

まずリント ​

game-lint は oxlint と ESLint のプラグインで、よくある原因をエディタの中で、どのエンジンでも見つけます。ルールは no-unseeded-random、no-wall-clock、no-real-timers、no-alloc-in-hot-path です。最後のものは決定性とは関係ありません。毎フレーム、ループの各要素ごとに作られるオブジェクト、配列、クロージャを指摘します。これらはガベージコレクタの負担になり、カクつきの原因になります。ルールはシミュレーションのコードに向けてください。起動処理やネットワークは、正当な理由で時計を読むことがあります。

oxlint 1.90.0 と game-lint 0.1.0 で、私たちのオープンソースのバレットヘブンゲーム overbuffed を検査しました。決定性の 3 つのルールは何も見つけませんでした。このゲームはすでにシード付き生成器と固定ステップを使っているからです。no-alloc-in-hot-path は警告を 3 件出しました。2 件はタイトル画面の render()、残る 1 件はメインのプレイループのこれです。

text
src/scenes/run.ts:390:31: warning game(no-alloc-in-hot-path): a closure inside a loop in step()
allocates for every item, every frame, ...
ts
for (const e of enemies)
  grid.near(e.x, e.y, 16, (o) => { /* push o away from e */ });

敵 1 体ごと、ステップごとに新しいクロージャが作られています。

リンターは知っている呼び出ししか見えません。ライブラリの中で Math.random() を呼ぶエンジンのヘルパーは出てこないので、次の確認が必要です。

次に 2 回再生して比べる ​

同じシードと入力で、まっさらな状態から 2 回実行し、ステップごとにゲームの状態を比べます。最初に食い違ったステップが、どこを見ればいいかを教えてくれます。2 回目を別の Math.random() シードと別の Date.now() で実行すると、ライブラリ経由であってもそれらを読んでいる箇所がわかります。

debuff の場合 ​

debuff にはこれが組み込まれています。@debuffjp/core はロジックを FixedClock(デフォルトは 1/60 秒のステップ)で動かし、どのゲームにもシード付きの game.random を用意します。これは mulberry32 の Random で、state を保存して戻せます。その上に次のものがあります。

  • Keiko の test.deterministic は、まっさらなゲームでスクリプトを 2 回実行し、2 回目はシード(game.random と Math.random() の両方)と日付を変えます。そして最初に食い違ったティックとフィールドを報告します。Keiko はほかのエンジンのゲームでも動きます。
  • debuff fuzz はシード付きのランダム入力でゲームをヘッドレスに遊び、見つけたクラッシュ、停止状態、ループをそれぞれ、その実行を再生するシナリオファイルとして保存します。リプレイとファジングでゲームをテストするを参照してください。
  • 開発パネルはセッションを記録し、巻き戻しスライダーで選んだティックまで入力を再生して一時停止します。

どれもゲームが決定的だからこそ動くものです。だから debuff はすべてのプロジェクトをその形で始めます。

この記事で扱わないこと ​

内部で独自のタイミングを持つ物理エンジンや Web Worker には、それぞれずれる原因があります。マシンをまたぐロックステップには、入力遅延、ロールバック、同期ずれの検出など、この記事以上のものが必要です。ここでの確認は、1 台のマシンと 1 つのビルドでゲームを再現できるようにするもので、リプレイ、テスト、ファジングにはそれで足ります。

試してみる ​

bash
npm install --save-dev game-lint

あとは game-lint の README の設定で、シミュレーションのコードに向けてください。