決定論と固定小数点
エンジンの担当 この章は、エンジンの内側の話です。bot を書くだけなら、読まなくても大丈夫です。
CodeTank Arena の試合は、動画として保存されていません。
保存されているのは、シード(数字ひとつ)と bot のコードだけ。 再生するたびに、その場でもう一度計算し直しています。
これが成り立つには、条件がひとつだけあります。
同じ入力からは、どの端末でも、1 ビットも違わない同じ結果が出ること。
これを決定論といいます。このページはその話です。
なぜそこまでするのか
Section titled “なぜそこまでするのか”理由は 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 フレーム積み重なれば、弾は当たるか外れるかの差になります。
だからエンジンは、小数を物理に使いません。
16.16 固定小数点
Section titled “16.16 固定小数点”すべての位置・速度・角度を整数で持ちます。
考え方はとても簡単で、1 マスを 65536 に分けて数えるだけです。
| 実際の値 | 内部の整数 |
|---|---|
| 1.0 マス | 65536 |
| 0.5 マス | 32768 |
| 3.42 マス | 224133 |
小数点の上に 16 ビット、下に 16 ビット。だから「16.16」と呼びます。
角度も同じです。1 周を 65536 に分けます。
| 角度 | 内部の整数 |
|---|---|
| 0 度(右) | 0 |
| 90 度(下) | 16384 |
| 180 度(左) | 32768 |
| 360 度 | 65536(= 0 に戻る) |
整数どうしの足し算・引き算は、どの端末でも完全に同じ答えになります。 これで 1 つ目の問題は消えました。
かけ算だけは、ひと工夫いる
Section titled “かけ算だけは、ひと工夫いる”足し算はそのままでよいのですが、かけ算には注意が必要です。
1.5 × 2.0 を固定小数点でやると:
98304 × 131072 = 12884901888これは「1.5 マス × 2.0 マス」ではなく、65536 倍だけ大きすぎる数です。 2 回かけたぶん、65536 が 2 回かかっているからです。1 回ぶん割り戻します。
12884901888 ÷ 65536 = 196608 ← 3.0 マス。正解ここで、多くの人がやってはいけない書き方をします。
この禁止事項は一覧になっていて、エンジンの中で
>> 16 や | 0 を使っていないか検査するようになっています。
端数の丸めかたも決めてある
Section titled “端数の丸めかたも決めてある”割り切れないときの丸めも、決めておかないとずれます。 このエンジンは 「0.5 ちょうどは偶数側へ」(round half to even)です。
2.5 → 2 3.5 → 4 4.5 → 4いつも切り上げると、丸めの誤差が片側にたまっていきます。 偶数側に寄せると、たまりかたが打ち消し合います。
sin は計算せず、表を引く
Section titled “sin は計算せず、表を引く”角度が要る計算(向きから速度を出すなど)には sin と cos が必要です。
でも Math.sin は使えません。答えが端末によって違うかもしれないからです。
そこで **表(ルックアップテーブル)**を使います。
- 1 周を 1024 に分けた
sinの値を、あらかじめ計算しておく - その 1024 個の数字を、ソースコードとしてリポジトリに保存する
- 実行時はその表を引き、あいだは直線でつなぐ
SIN_LUT = [0, 402, 804, 1206, ...] ← 1024 個。これがソースに書いてあるatan2(座標から角度を出す関数)は、エンジンではそもそも使っていません。
角度の比較は内積と二乗の比較だけで済ませています。
たとえば「正面 45 度以内か」は、平方根も逆三角関数も使わずにこう書けます。
の両辺を 2 乗して整理しただけです。 厳密で、しかも整数だけで計算できます。
なお、これはエンジンの中だけの制限です。
あなたの bot は math.sin も math.atan2 も自由に使えます。
乱数も決まっている
Section titled “乱数も決まっている”マップ生成には乱数を使いますが、シードから決まる乱数です。 同じシードなら、何度やっても同じマップができます。
bot の rand() も同じです。試合ごと・陣営ごとにシードが固定されているので、
同じ試合を再生すれば、同じ目が出ます。
random モジュールが使えないのはこのためです。
本物の乱数を混ぜた瞬間に、再現できなくなります。
「順番」もぜんぶ決めてある
Section titled “「順番」もぜんぶ決めてある”数の表しかたを揃えても、処理の順番が違えば結果は変わります。 だからエンジンは、迷いどころを 1 つずつ潰してあります。
| 同時に起きたとき | 決めてあること |
|---|---|
| bot を動かす順 | つねに A → B |
| 壁にめりこんだ | X 方向を先に押し戻し、次に Y |
| 2 台が完全に重なった | A を −X、B を +X へ |
| 弾が壁の角に当たった | X 軸の反転を優先 |
| 視線がマスの角を通った | X 方向へ進む |
| 弾が同時に相殺した | 生成順 ID の昇順 |
| BFS で道を広げる向き | 右 → 下 → 左 → 上 |
どれも「どちらでも正しい」場面です。 正しさではなく、決まっていること自体に意味があります。
PyLite(bot の Python)にも同じ配慮があります。
dict は入れた順に回り、sorted は安定ソート、set はそもそも用意していません。
集合は要素を回す順番が実装に左右されやすいからです。
壊れていないか、どう確かめるか
Section titled “壊れていないか、どう確かめるか”決定論は「気をつける」では守れません。テストで縛ってあります。
- ゴールデンハッシュテスト — 決まった 50 組の試合の結果ハッシュが、固定値と一致するか
- クロス環境テスト — Node / Bun / Chromium / Firefox / WebKit の 5 つで同じハッシュになるか
- 分割実行テスト — 途中で止めて保存し、読み直しても同じ結果になるか
- 命令数一致テスト — bot が使った命令数が、環境によらず完全に一致するか
- 静的検査 — 禁止した書き方(
>> 16、Math.sin、Date…)が紛れこんでいないか
- 1 フレームの中で何が起きているか → フレームの 6 フェーズ
- 角のタイブレークの実例 → 跳弾のしくみ
- bot 側の制限とその理由 → Sub-Python の構文