Kimetron Isle の上に積んだ移動仲裁層「SAGBAL」の話、最終回です。前回までで機構の設計を書きました——スコア競り・譲りヒステリシス・相互退避・拡散・爆発。
今回は検証の話です。「迷路に放すと壊れるか」を確かめるために、デモを拡張して実際にストレスをかけました。結果は「異常は全部こっち(アプリ側)のバグだった」という、設計者としては複雑な気分になる結論です。そしてその後、実際のゲームに組み込んだ段階で、層自身のバグもひとつ見つかりました。
動機: 街路で使えるか
別プロジェクトで進めている北宋時代の生存シミュレーション SLG があって、そこでは建物で区切られた街路網をキャラが行き来します。廊下幅はユニット1〜2体分。SAGBAL をそこに載せるには、廊下・袋小路のある環境で使い物になることを先に証明する必要がありました。
迷路と局所経路
深い迷路への全経路探索はしません。「辻まで5〜6キャラ分」の局所経路で十分という線引きにしました。街路・扉・行き先は意味のある地図データで、これは App の領分です。SAGBAL は経路を持たない。食べるだけ。
迷路は 3×3 の建物ブロックで、縦3本×横3本の街路が残ります。廊下幅 640(ユニット2体分強)、辻は9箇所。袋小路も意図的に残しました——袋小路での振る舞い自体が観察対象だからです。
局所プランナーは App 側にだけ実装しています。ゴール設定時に「6キャラ分先まで触覚レイを投げ、通行可能な最小偏向角を貪欲に選ぶ」ロールアウトを回して Goal.via の列を生成し、それを層に渡します。SAGBAL 側は無改造で済みました——via は既存の機能で、迷路対応と見せかけて実は経路供給の話でした。
初回計測: 全部異常に見えた
?maze=1 で起動して 90 秒走らせ、イベント種別ごとの発生回数を集計しました。
| 事象 | 回数 | 所感 |
|---|---|---|
| blocked | 1501 | 壁張りつき多発 |
| explode | 250 | 爆発ループ |
| diffuse | 1832 | 発信の洪水 |
| goal-switch | 307 | 目的地切替の空転 |
| deadlock | 31 | 少なめ(廊下でも横ずれは成立) |
| arrived | 22 | 遅い |
数字だけ見ると酷いのですが、特に際立ったのは uE というユニットのストレスで、実測 1126 まで無制限に蓄積していました。爆発も goal-switch も繰り返しています。「層の設計が迷路で破綻した」ように見えました。
原因: スポーン時点で壁に埋まっていた
unit() に中心座標を渡していたバグでした。この API は左上隅を取ります。uE は指定 (1466,2900) のつもりが実際の中心は (1616,3050) で、下端が南壁(3080–3200)に 120 ピクセル埋没していました。
埋没位置からは全方向の tryMove が物理的に拒否されます。永久に boxed → 拡散も無意味(壁は拡散しない)→ ストレスは溜まり続け、爆発しても動けない。これが stress 1126 の正体です。
つまりここで起きていたのは、Kimetron の配置検査が正しく機能していた、ということでした。物理的に不正な位置を「不正」として報告し続けていただけです。設計が守られた結果、デモがバグった。
ついでにプランナー側のバグも潰れました。ウェイポイントをレイ終端の 60px 手前に置いていたのですが、ユニットの「鼻先」(中心から150px)が壁にめり込む到達不能点を生成していました。マージンを 190 に増やして解消です。
修正後の計測: 健全だった
全スポーン座標を左上隅基準に直して走り直した 60 秒の結果です。
| 事象 | 回数 | 解釈 |
|---|---|---|
| blocked | 500 | ユニット同士の対面が主。壁沿いスライドの残滓 |
| deadlock / mutual-retreat | 77 / 56 | 対面衝突は多いが相互退避で解消 |
| contention / yield | 55 / 76 | 競りは機能 |
| diffuse / boxed | 160 / 29 | 一時閉塞→発信→解消の健全サイクル |
| explode | 46 | 実閉塞由来の正常範囲 |
| retreat-failed | 11 | 廊下の袋詰まりは少数だが実在 |
| arrived / dwell | 20 / 22 | 交通は継続して流れている |
イベントログには判断の語彙が全部流れます。deadlock uA ↔ uC — both sidestep(同点は両者退避)、uC boxed in — broadcasting → uD scatters from uC(閉塞→発信→周辺が散る)、uC explodes (stress 12)(爆発で押し通し)。uE blocked by uP は駐機したプレイヤー P への衝突で、手が競りに参加している様子が見えます。
この計測が証明したこと
設計の観点では、2つのことが分かりました。
ひとつ、層の境界は健全でした。 観測された異常(壁めり込み・爆発ループ・拡散洪水)は全部 App 側のバグで、SAGBAL 層は「物理的に不正な位置」という事実を正しく報告し続けていただけです。事実が濃い層を挟んだおかげで、異常の原因が「どの層の責任か」はすぐに切り分けられました。
もうひとつ、語彙は足りていました。 廊下・辻・袋小路の全てで、deadlock → sidestep、contention → yield、boxed → diffuse → scatter、explode → 押し通し——交渉が決着しています。「総意の限界」として疑っていた、対向流の破綻も、この規模では観測できませんでした。
その後: 実投入で出たのは層側のバグ(v0.5.0)
迷路は件の北宋SLGのための検証でした。そのゲーム(モック1)に SAGBAL を実際に組み込み始めたところ、向こうのデバッグ計測からエスカレーションが来ました。「ユニットがユニットに塞がれているのに、blocked が壁を報告する」。
{"id":"u283","why":"3回中断しても壁rect(34,95,3,1)に進路を塞がれた",
"nearby":[{"id":"w:34,95,3,1","kind":"wall",…},{"id":"u:u284","kind":"polygon",…}]}
壁は東に接し、別ユニット u284 は真北にいる——という状況です。原因は collisions() が doc 登録順でヒットを返すこと。意図矩形が壁とユニットに同時に重なった時、先に登録された壁がブロッカーとして選ばれていました。アプリ側の「塞ぎ手をどかす」処理は相手がユニットの時にしか回らないので、詰まりが解消しません。repeat-block(同一相手カウント)も同じ誤帰属を食べていました。
v0.5.0 で直しました。blockerId は「層が交渉・移動できる相手」(登録済みエージェント・外部意図)を優先して返し、全ヒットを blockerIds として丸ごと出すようにしました。
ここでひとつ設計の話をします。エスカレーション側の提案は「kind !== 'wall' を優先する」でした。尤もですが、kind の値 'wall' はデモと向こうのアプリが勝手に使っている語彙で、パッケージに焼き込むと第1回の「語彙を逆流させない」ルールに触れます。代わりに「層自身が交渉できる相手」という、層の中の知識だけで判定する形にしました。
このバグは少し恥ずかしいものでした。「SAGBAL は事実を飲み込まない」と書いた層が、重なった相手から doc 順の先頭1件だけを選ぶことで、事実を欠落させていたわけです。帰属も事実の一部で、選び方を間違えると事実は嘘をつきます。 迷路計測では「異常は全部 App 側のバグ」と結論したのですが、実投入で出てきたのは層自身の——機構ではなく、報告の仕方のバグでした。
向こう側には暫定ワークアラウンド(壁が返った時は近接ユニットもどかす)が入っているので、v0.5.0 の取り込みで撤去できる見込みです。
残っている弱点
バグを直したあとも、計測で残った課題があります。
- 廊下対応退避(P22): boxed-by-wall は拡散では解けません(壁は散らない)。袋小路に 3 体以上が殺到したときの出口が要ります。来た道を戻る「経路沿い後退」か「辻待機」の設計が必要
- diffuse の洪水抑制(P24): boxed 継続中は毎 tick 全近傍へ発火します。ペア毎クールダウンか、エッジ型イベントへの変更が要検討
- 検出の感度(P18): 廊下幅640だと対面すれ違いでも deadlock が発火し得ます。blocked 実績での絞り込み候補
現状の線引き
SAGBAL は「移動の仲裁層」としては実用域ですが、ゲームエンジンではありません。描画・入力・経路探索は App の領分、掃引衝突判定もありません(着地地点の検査が Kimetron の正解)。npm 公開もこれからです。
ただ、「移動の事象を中間層に集約する」というコメント欄発の構想は、v0.4.0 で閉塞の棲み分け(幾何衝突は規約、意味競合は総意、完全閉塞は拡散)が成立し、v0.5.0 で下流からの初エスカレーション(ブロッカー誤帰属)を潰すまで来ました。迷路テストと実投入で分かったのは、機構はまだ壊れていない——ただし事実の「出し方」にもバグはあり得る、ということです。
追記: 執筆中もエスカレーションが続きました(→ v0.8.0)
この記事を書いている間にもモック1の計測からエスカレーションが続き、性能面で3件積みました。
-
v0.6.0 —
Goal.step。ユニット自身が「1ティックの歩幅」を指定できるように。向こう側が自前で持っていたスロットルを層の語彙として上流へ移管しました - v0.7.0 — ティック毎のエージェント索引と一様グリッドの近傍走査。ユニットが増えても競合検出が全ペア総当たりにならないようにしました
-
v0.8.0 — オブジェクトキャッシュの差分パッチ・起床コールバックの最適化・密度不変時のスキャン省略・進捗リングバッファ。加えて
crowdScanEvery(群衆走査の間隔)とcrowdShard(走査シャード)という、呼び出し側が粒度を調整できるノブを公開
このエスカレーションは、エンジン側の作業も生みました。「ユニット数が増えると objectsIn/collisions が毎回 O(D) の全走査になる」——これは層の性能問題ではなくエンジンの物理クエリの話なので、領分に従って Kimetron Isle v1.3.0 に空間索引を持たせました。第1回で書いた「層と語彙の住み分け」が、ここでは作業の住み分けとして効いています。
「残っている弱点」の3件はそれでも健在です。機能はまだこれから、性能は段階的に潰せている——というのが現状です。
3回に分けて書いた SAGBAL の話は以上です。
シリーズ過去回:
リポジトリはこちら。

