コンテンツにスキップ

決定論と固定小数点

エンジンの担当 この章は、エンジンの内側の話です。bot を書くだけなら、読まなくても大丈夫です。

CodeTank Arena の試合は、動画として保存されていません。

保存されているのは、シード(数字ひとつ)と bot のコードだけ。 再生するたびに、その場でもう一度計算し直しています。

これが成り立つには、条件がひとつだけあります。

同じ入力からは、どの端末でも、1 ビットも違わない同じ結果が出ること。

これを決定論といいます。このページはその話です。

理由は 3 つあります。

1. リプレイが軽い。 60 秒の試合が数字の列だけで済みます。学校の回線でも配れます。

2. 巻き戻し・コマ送りがタダで手に入る。 どの瞬間からでも計算し直せるからです。

3. ずるを見破れる。 将来ほかの人と対戦するとき、 「この試合結果は本当か」を手元で計算して確かめられます。 結果が 1 ビットでも違えば、どちらかが嘘をついています。

3 番目のために、1 機種でも答えがずれると成り立ちません。 iPad でも Chromebook でも Windows でも、完全に同じでなければならないのです。

小数は、端末によって答えが変わる

Section titled “小数は、端末によって答えが変わる”

いちばんの敵は小数です。

コンピュータの小数(浮動小数点数)は、四則演算と平方根までは規格で厳密に決まっています。 けれど sin や cos のような関数は、最後の桁が実装しだいです。規格は保証していません。

Chrome の Math.sin(1.0) = 0.8414709848078965
別の環境の Math.sin(1.0) = 0.8414709848078966 ← 最後の桁だけ違う

たった 1 桁。でも 3600 フレーム積み重なれば、弾は当たるか外れるかの差になります。

だからエンジンは、小数を物理に使いません。

すべての位置・速度・角度を整数で持ちます。

考え方はとても簡単で、1 マスを 65536 に分けて数えるだけです。

実際の値内部の整数
1.0 マス65536
0.5 マス32768
3.42 マス224133

小数点の上に 16 ビット、下に 16 ビット。だから「16.16」と呼びます。

16.16固定小数点は上位16ビットが整数部、下位16ビットが小数部。3.42マスは整数部3と小数部0.42マスぶんの合計224133として表される図

角度も同じです。1 周を 65536 に分けます。

角度内部の整数
0 度(右)0
90 度(下)16384
180 度(左)32768
360 度65536(= 0 に戻る)

整数どうしの足し算・引き算は、どの端末でも完全に同じ答えになります。 これで 1 つ目の問題は消えました。

足し算はそのままでよいのですが、かけ算には注意が必要です。

1.5 × 2.0 を固定小数点でやると:

98304 × 131072 = 12884901888

これは「1.5 マス × 2.0 マス」ではなく、65536 倍だけ大きすぎる数です。 2 回かけたぶん、65536 が 2 回かかっているからです。1 回ぶん割り戻します。

12884901888 ÷ 65536 = 196608 ← 3.0 マス。正解

ここで、多くの人がやってはいけない書き方をします。

この禁止事項は一覧になっていて、エンジンの中で >> 16 や | 0 を使っていないか検査するようになっています。

割り切れないときの丸めも、決めておかないとずれます。 このエンジンは 「0.5 ちょうどは偶数側へ」(round half to even)です。

2.5 → 2 3.5 → 4 4.5 → 4

いつも切り上げると、丸めの誤差が片側にたまっていきます。 偶数側に寄せると、たまりかたが打ち消し合います。

角度が要る計算(向きから速度を出すなど)には sin と cos が必要です。 でも Math.sin は使えません。答えが端末によって違うかもしれないからです。

そこで **表(ルックアップテーブル)**を使います。

  • 1 周を 1024 に分けた sin の値を、あらかじめ計算しておく
  • その 1024 個の数字を、ソースコードとしてリポジトリに保存する
  • 実行時はその表を引き、あいだは直線でつなぐ
SIN_LUT = [0, 402, 804, 1206, ...] ← 1024 個。これがソースに書いてある

atan2(座標から角度を出す関数)は、エンジンではそもそも使っていません。 角度の比較は内積と二乗の比較だけで済ませています。 たとえば「正面 45 度以内か」は、平方根も逆三角関数も使わずにこう書けます。

2 (f⋅d)2≥∥d∥2かつf⋅d>02\,(\mathbf{f} \cdot \mathbf{d})^2 \ge \Vert{}\mathbf{d}\Vert{}^2 \quad\text{かつ}\quad \mathbf{f} \cdot \mathbf{d} > 0

cos⁡45°=1/2\cos 45° = 1/\sqrt{2} の両辺を 2 乗して整理しただけです。 厳密で、しかも整数だけで計算できます。

なお、これはエンジンの中だけの制限です。 あなたの bot は math.sin も math.atan2 も自由に使えます。

bot の math も、どの端末でも同じ答え

Section titled “bot の math も、どの端末でも同じ答え”

ただし、ブラウザにもともと入っている Math.sin や Math.atan2 は、最後の桁が端末によってちがいます。 実際に 2 つの実行環境で同じ 20 万個の数を入れてみると、atan2(向きを出す計算)は 5 回に 1 回くらい、 いちばん下の桁がずれていました。

angle_to(向き)・distance(距離)・**(べき乗)も、中でこうした計算を使っています。 そこで bot の math とこれらのヘルパーは、ブラウザの Math を使わず、 たし算・ひき算・かけ算・わり算と平方根だけで組み立てた計算で答えを出しています。 この 5 つは、どの端末でも答えが 1 通りに決まっている計算です。だから math.sin の答えも、どこで計算しても同じになります。 精度は、ふつうの Math.sin とほとんど変わりません(ちがっても最後の 1 桁)。

マップ生成には乱数を使いますが、シードから決まる乱数です。 同じシードなら、何度やっても同じマップができます。

bot の rand() も同じです。試合ごと・陣営ごとにシードが固定されているので、 同じ試合を再生すれば、同じ目が出ます。

random モジュールが使えないのはこのためです。 本物の乱数を混ぜた瞬間に、再現できなくなります。

数の表しかたを揃えても、処理の順番が違えば結果は変わります。 だからエンジンは、迷いどころを 1 つずつ潰してあります。

同時に起きたとき決めてあること
bot を動かす順つねに A → B
壁にめりこんだX 方向を先に押し戻し、次に Y
2 台が完全に重なったA を −X、B を +X へ
弾が壁の角に当たったX 軸の反転を優先
視線がマスの角を通ったX 方向へ進む
弾が同時に相殺した生成順 ID の昇順
BFS で道を広げる向き右 → 下 → 左 → 上

どれも「どちらでも正しい」場面です。 正しさではなく、決まっていること自体に意味があります。

PyLite(bot の Python)にも同じ配慮があります。 dict は入れた順に回り、sorted は安定ソート、set はそもそも用意していません。 集合は要素を回す順番が実装に左右されやすいからです。

壊れていないか、どう確かめるか

Section titled “壊れていないか、どう確かめるか”

決定論は「気をつける」では守れません。テストで縛ってあります。

  1. ゴールデンハッシュテスト — 決まった 50 組の試合の結果ハッシュが、固定値と一致するか。50 組のうち 5 組は、math の答えのいちばん下の桁で動きを決める、わざと敏感に作った bot の試合です
  2. クロス環境テスト — ちがう実行環境(Node・Bun・Chrome、それに学校の iPad や Chromebook)で回しても、同じハッシュになるか
  3. 分割実行テスト — 途中で止めて保存し、読み直しても同じ結果になるか(これから作ります)
  4. 命令数一致テスト — bot が使った命令数が、環境によらず完全に一致するか
  5. 静的検査 — 禁止した書き方(>> 16、Math.sin、Date …)が紛れこんでいないか