Rust のビヘイビアツリーライブラリ bonsai(crates.io では bonsai-bt)が、先日 crates.io で累計 10 万ダウンロードを突破しました。作者が r/rust に投稿した達成報告には、こんな採用事例が並んでいます。
- NASA Lunabotics 2026:ユタ大学チームの月面ローバーの自律制御に採用。模擬月面でレゴリスを掘って運ぶ自律走行を bonsai のビヘイビアツリーが統括し、自律部門で 50 チーム中 3 位
- Titanfall 2 の Mod サーバー:bp-ort(Northstar 用 NPC プラグイン)で、プレイヤー以外全員が bonsai 駆動のボット
- Anotherland:MMORPG サーバーの NPC 行動制御
ロボットとゲーム AI の両方で実戦投入されているのが面白いところです。この記事ではbonsaiの基礎から、最後は bonsai を WASM にビルドし、ブラウザ上で 2 体のキャラクターに追いかけっこをさせるデモまで作ります。
👹(追う側)も 🐰(逃げる側)も、毎フレーム Rust の bonsai-bt が行動決定します
動作確認環境:bonsai-bt 0.13.0 / rustc 1.89 / wasm-pack 0.15 / macOS
ビヘイビアツリーとは
ビヘイビアツリー(Behavior Tree, BT)は、エージェントの行動ルールと実行順序を木構造で表現するデータ構造です。ゲーム NPC の AI として広まり、いまはロボティクス(ROS 界隈の BehaviorTree.CPP が有名)でも標準的に使われています。
各ノードは実行されると Success / Failure / Running のいずれかを返します。毎フレーム(tick)ルートから深さ優先で木をたどり、子の結果を見て親が次にどのノードを実行するか決める——これだけのシンプルな仕組みです。
有限ステートマシン(FSM)と比べたときの利点は、ロジックが育ったときの管理しやすさに尽きます。
| FSM | ビヘイビアツリー | |
|---|---|---|
| ノード間の関係 | 多対多(遷移が絡み合う) | 一対多(木構造) |
| 優先度の表現 | 遷移条件に埋め込む | 左から右への並び順そのもの |
| 部品の再利用 | 状態が文脈に密結合しがち | サブツリーごと使い回せる |
FSM は状態が 10 個を超えたあたりから遷移の網が手に負えなくなりますが、BT は「左にあるものほど優先」という規約が構造に埋め込まれているので、行動を追加しても見通しが保てます。
bonsai の基本
ツリーは Behavior enum を入れ子にして宣言的に組みます。主なコンビネータ:
Sequence([A, B]) // A が成功したら B へ
Select([A, B]) // A が失敗したら B を試す
If(cond, A, B) // cond 成功なら A、失敗なら B
Invert(A) // 成否を反転
While(cond, [A, B]) // cond が Running の間 A, B を繰り返す
WhileAll(cond, [A, B]) // A, B を全部完了するごとに cond をチェック
WhenAll([A, B]) // 並列実行して両方の成功を待つ
WhenAny([A, B]) // 並列実行してどれかの成功を待つ
Race([A, B]) // 並列実行して成否問わず最初の完了を待つ(v0.11 で追加)
WhenAll / WhenAny / Race のような並列セマンティクスを最初から持っているのが bonsai の特徴です。「充電残量の監視と経路走行を並走させ、どちらかが終わったら次へ」のようなロボット的な要求が、スレッドを自分で管理せずツリーの宣言だけで書けます。
もうひとつの部品が Blackboard(黒板)。ツリー内の全ノードが読み書きできる共有メモリで、任意の型をジェネリクスで渡せます。
そして bonsai の設計の肝は、ツリーの構造(宣言)と処理の実装が完全に分離していること。Action の中身は自分で定義した enum のバリアントだけで、実際の処理は tick に渡すクロージャに書きます。
let status = bt.tick(&e, &mut |args, blackboard| {
match *args.action {
EnemyNPC::Shoot => {
state.perform_action("shoot");
blackboard.times_shot += 1; // 黒板に書き込み
(Success, 0.0)
}
EnemyNPC::Rest => {
if state.fully_rested() {
return (Success, 0.0);
}
state.rest();
(Running, 0.0) // Running を返すと次の tick でまたここに来る
}
// ...
}
});
まず公式サンプル simple_npc_ai(アクションポイント制の NPC)を動かしてみると、Rest が Running を返している間は制御がメインループに戻り、tick のたびに回復が 1 ずつ進む——「長い処理を Running で分割する」という BT の感覚がすぐつかめます。
NPC has action points: 3
Performing action: run. Action points: 2
Performing action: shoot. Action points: 1
...
Rested for a while... Action points: 3
NPC died...
NPC shot 2 times during the simulation.
get_graphviz出力について
公式サンプルには bt.get_graphviz() でツリーを Graphviz 出力するコードがありますが、そのままだと 0.13.0 ではコンパイルエラーになります。可視化系 API は visualize feature に移動しているためです。
[dependencies]
bonsai-bt = { version = "0.13.0", features = ["visualize"] }
これで get_graphviz() が実行可能です。
デモ:BT 同士の追いかけっこをブラウザで動かす
ここからが本題です。bonsai-bt は wasm32-unknown-unknown でそのままビルドできるので、Rust で書いた BT をブラウザの three.js シーンに直結できます。冒頭の GIF がその結果です(キャラクターは開発中の自作ゲームの box-person モデルを流用しています。)コード全体は yonaka15/bonsai-chase-demo に置きました。
構成:
-
Rust (WASM):2 体分の BT と世界状態(位置・スタミナ)。毎フレーム
tick(dt, 相手の座標)を呼ぶと行動決定+移動積分して状態を返す - JS (three.js):返ってきた状態を描画するだけ。行動ロジックは書かない
wasm ビルドで唯一必要だったワークアラウンドは getrandom の問題だけでした。
[dependencies]
bonsai-bt = "0.13.0"
getrandom = { version = "0.2", features = ["js"] } # wasm32 ビルドに必要
wasm-bindgen = "0.2"
逃げる側のツリー
「ハンターが近い間は逃げる」>「力尽きたら座り込む」>「それ以外は徘徊」という優先度を、そのまま Select の並び順で表現します。
use bonsai_bt::{Action, Select, Sequence, BT};
let tree = Select(vec![
Sequence(vec![Action(HunterNear), Action(CanFlee), Action(Flee)]).memory(false),
Sequence(vec![Action(NeedRest), Action(Rest)]).memory(false),
Action(Wander),
])
.memory(false);
ポイントは .memory(false)(v0.13 で入った memoryless composite)です。通常の Select は Running 中の子を次の tick でも再開しますが、memoryless にすると毎 tick 左から再評価されます。つまり徘徊中にハンターが接近した瞬間、次の tick で Flee が徘徊を横取り(プリエンプト)します。優先度付き反応 AI はこれだけで書けます。
追う側も同じ形のツリーで、Flee が Chase に変わるだけです。両者のパラメータ差——逃げ足 3.6 に対して追い足 1.7、ただし逃げる側はスタミナ切れが早い——から、「元気なうちは絶対捕まらないが、疲れて座り込んだところに追いつかれる」という追いかけっこのドラマが創発します。どちらのキャラにも「追いかけっこをしろ」とはどこにも書いていません。
ハマったところ:memoryless Select はチャタリングする
毎 tick 再評価は強力ですが、条件が境界値で振動すると行動も振動します。
その 1:FLEE↔REST の高速振動。 「スタミナ > 0 なら逃げられる」という条件にしていたら、座り込んで 0.01 回復した瞬間また逃げ出し、即座に力尽きて座り……を毎秒数回繰り返す壊れた動きに。対策はヒステリシスで、「一度力尽きたら 55% 回復するまで逃走不可」というフラグを黒板に持たせました。
// tick の冒頭で更新(力尽きた ⇔ 十分回復、の間に不感帯を作る)
if bb.stamina <= 0.0 {
bb.exhausted = true;
} else if bb.stamina >= 0.55 {
bb.exhausted = false;
}
その 2:逃走半径の境界振動。 逃げて半径 1.8 の外に出た瞬間 Wander に戻り、ハンターが 1 歩詰めてまた Flee……と、境界線上で行動がパタパタ切り替わる。これも「逃走開始 1.8 / 解除 2.4」と閾値を 2 つに分けて解決しました。
BT 自体の問題ではなく反応型 AI の普遍的な罠ですが、「条件ノードの閾値には不感帯を持たせる」は memoryless composite を使うときの必須テクだと思います。
JS 側は状態を受け取って描くだけ
import init, { Npc, Hunter } from './pkg/bonsai_vrm_demo.js';
await init();
const prey = new Npc(42);
const hunter = new Hunter(7);
let sPrey = { x: 0, z: 0 }, sHunter = { x: -1.8, z: -1.8 };
function frame(now) {
const dt = Math.min((now - last) / 1000, 0.05);
sPrey = prey.tick(dt, sHunter.x, sHunter.z); // Rust の BT が行動決定
sHunter = hunter.tick(dt, sPrey.x, sPrey.z);
// → 返ってきた {x, z, heading, action, stamina} を three.js で描画
}
HUD にはツリーをそのまま表示し、アクティブなノードをハイライトしています。BT の「いまどの枝が生きているか」が見えると、デバッグが劇的に楽になります——今回は簡易的ですが、次に紹介する公式のライブ可視化機能もあります。
ライブ可視化と Python バインディング
最近のリリースで大きいのはこの 2 つです。
v0.12.0:ライブ可視化。visualize feature を有効にすると、実行中のツリーをブラウザからリアルタイムに眺められます。どのノードが Running でどこで Failure が起きたかが視覚的に追えるので、BT デバッグの printf との戦いから解放されます。使い方はビルダーを 1 行足すだけです。
let mut bt = BT::new(tree, world).with_telemetry(8910)?; // あとは普通に tick するだけ
実際に今回のデモの「逃げる側」ツリーをネイティブで動かし、http://127.0.0.1:8910/ を開いたのがこの画面です。
FLEE 中の瞬間。HunterNear と CanFlee が Success(緑)、Flee までの経路が Running(黄)に光り、休憩系の枝は未訪問のまま——「いまどの枝が生きているか」が一目で分かる
v0.13.0:Python バインディング。pip install bonsai-bt で Python からも同じツリーが使えます。Rust で書いたコアを Python のプロトタイピングから触れるのは、ロボティクス用途だと特に便利そうです。
長時間タスクの扱い
BT の鉄則は「アクションは即座に返す」です。tick がブロックするとツリー全体が止まるので、秒単位かかる処理(経路計画、API 呼び出しなど)はバックグラウンドスレッドに逃がし、channel 越しに状態を受け取って Running を返し続けます。公式の async_drone サンプル も参考にしてみてください。
まとめ
- bonsai は Rust 製ビヘイビアツリーライブラリ。10 万 DL、NASA Lunabotics 3 位のローバーから Titanfall 2 ボットまで実戦投入例あり
- ツリー宣言(
Behaviorenum)と処理実装(tick クロージャ)が分離した設計。並列セマンティクスと Blackboard を標準装備 -
.memory(false)の memoryless composite で優先度プリエンプションが書ける。ただし条件の閾値にはヒステリシスを - wasm32 でそのままビルドできるので、ブラウザのデモ・ゲームにも直結できる
-
get_graphviz()など可視化系はvisualizefeature が必要

