おはこんばんにちは!
先月の頭辺りから、Rust の勉強がてら、ターミナルで遊べるトランプゲームを作っています。
今回は、初めに作ってみたババ抜き(Old Maid)について書いて行こうと思います。
ババ抜きについて、ゲームエンジンや描画より先に「CPU がどう考えるか」から手を入れた結果、
引かれた位置の履歴を記録してジョーカーを隠すという、ちょっと面白い仕組みができました。
その設計の考え方と Rust での実装を紹介して行こうかなと。
- リポジトリ: yoshitaka-k/old_maid
- 起動:
cargo run

▲ cargo run 直後。FIGlet の「Old Maid」タイトルとバージョン行が見える状態。
- ババ抜きで CPU に求めるもの
- 履歴の記録 — 「どこから引かれたか」
- 頻度集計 — 「よく引かれる場所」を特定する
- ジョーカーの隠し方 — 4 パターン
- データの流れ(全体像)
- 設計で意識したこと
- ジョーカーボーナス — ルールとの連動
- 動かしてみる
- まとめ
- 次回予告
- 参考リンク
ババ抜きで CPU に求めるもの
ババ抜きは、相手の手札から 1 枚引いてペアを捨てていくゲームです。
最後にジョーカー(ババ)を残した人が負け。
CPU に求める振る舞いは、大きく 2 つに分かれます。
- 自分がジョーカーを持っているとき — どこに隠すか
- 相手の手札を引くとき — どの位置を選ぶか
最初は「ランダムに引く」だけでも動きます。
でも実際に遊ぶと、同じ位置ばかり引かれることが起きます。
人間なら「この辺、よく引かれるな」と感じて手札の並びを変えるはずです。
そこで CPU にも 履歴 を持たせることにしました。

▲ 人間プレイヤーのターン。Round: X / Turn: Y / Player (Human) と、相手から引く入力プロンプトが表示されている状態。
履歴の記録 — 「どこから引かれたか」
Player 構造体の中に History を持たせ、ターンごとにインデックスを記録します。
struct History { // ... choose_index: Vec<usize>, // 自分が引いた位置 taken_index: Vec<usize>, // 相手に引かれた位置 }
ゲーム本編では、カードを引いた直後に「引かれた側」の履歴を更新します。
let pick_card_idx = run_player(players, current, target_player_idx); players[target_player_idx].add_history_taken_index(pick_card_idx);
左から数えた 0 始まりのインデックス として保存しています。
例えば手札が 7 枚あって、左から 3 枚目が引かれたら 2 が taken_index に追加されます。
頻度集計 — 「よく引かれる場所」を特定する
手札を並び替えるタイミングで、履歴から 引かれた回数の多い順 にインデックスを並べます。
fn values_by_descending_frequency(&self) -> Vec<usize> { let mut counts: HashMap<usize, usize> = HashMap::new(); for &idx in &self.taken_index { *counts.entry(idx).or_insert(0) += 1; } let mut pairs: Vec<(usize, usize)> = counts.into_iter().collect(); pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); pairs.into_iter().map(|(k, _)| k).collect() }
同じ回数ならインデックスが小さい方を優先する、シンプルなルールです。
ここで大事なのは、完璧な記憶 AI ではない という点。
手札を一度シャッフルしてからジョーカーを移動させるので、他のカードの並びはランダムのまま残ります。
「履歴をヒントにしつつ、完全に計算された並びには見えない」バランスにしています。
ジョーカーの隠し方 — 4 パターン
ジョーカーの移動ロジックは organize_hand.rs にまとめています。
| 関数 | 動き | 使う相手 |
|---|---|---|
joker_in_first |
左端(先頭) | Beginner CPU |
joker_in_last |
右端(末尾) | Medium CPU |
joker_in_center |
中央付近 | (予備) |
joker_in_history_taken |
よく引かれた位置 | Veteran CPU / 人間 |
Beginner — わざとバレやすく
fn organize_hand(&self, player: &mut Player) { player.sort_hand(); let hand = player.get_hand(); joker_in_first(hand); }
手札を数字順にソートしたうえで、ジョーカーを 左端 に置きます。
引く側も「左端か右端」をランダムに選ぶので、初心者 CPU は比較的読みやすい存在になります。

▲ CPU のターン。CPU 1 (Gambler) や CPU 2 (Medium)、 CPU 3 (Veteran) のように、括弧内に難易度名が出ている行。
Medium — 端っこに逃がす
fn organize_hand(&self, player: &mut Player) { let hand = player.get_hand(); hand.shuffle(&mut rand::thread_rng()); joker_in_last(hand); }
シャッフルしたあと 右端 にジョーカーを置きます。
引く側は 中央付近 を狙うので、「端に隠す vs 真ん中を引く」の単純な攻防になります。
Veteran / 人間 — 履歴を使う
fn organize_hand(&self, player: &mut Player) { let history_token = player.get_history_token_frequency(); let hand = player.get_hand(); hand.shuffle(&mut rand::thread_rng()); joker_in_history_taken(hand, history_token); }
核心部分はここです。
pub fn joker_in_history_taken(hand: &mut Vec<Card>, history_token: Vec<usize>) { if let Some(joker_index) = hand.iter().position(|c| c.is_joker()) { let insert_index = history_token .into_iter() .find(|&i| i < hand.len()) .unwrap_or(0); let joker = hand.remove(joker_index); hand.insert(insert_index, joker); } }
頻度順のリストの先頭から、現在の手札枚数の範囲内にある最初のインデックス にジョーカーを置きます。
なぜ「よく引かれる場所」に置くのか?
直感と逆に感じるかもしれません。
「引かれにくい場所に隠すべき」では?
この実装の意図は、相手の引き方のクセを利用する ことに近いです。
- 相手が毎回「左から 2 枚目」を引くクセがある
- その位置にジョーカーを置いても、相手は 同じ位置を機械的に選び続ける
- 結果として、ジョーカーが引かれにくくなる
逆に言えば、相手が完全ランダムに引く Veteran CPU 相手 では、この戦略の効果は薄れます。
実際、Veteran の choose_card は rand_range(0..len) でランダムです。
つまりこの設計は、
- 人間プレイヤー(同じ番号を何度も入力しがち)
- Beginner / Medium CPU(端や中央など偏った引き方)
に対して効きやすい、というゲームバランスになっています。
データの流れ(全体像)
ターン開始
↓
相手が pick_card_idx を決定
↓
引かれた側: taken_index に記録
↓
ペアがあれば捨てる
↓
organize_hand() 呼び出し
├─ 手札をシャッフル
└─ 難易度に応じてジョーカーを移動
└─ Veteran/人間: 履歴の頻度順インデックスへ
↓
次のターンへ
設計で意識したこと
1. 履歴は「ヒント」であって「正解」ではない
履歴をそのまま最適手として使うのではなく、シャッフル後に 1 箇所だけ ジョーカーを動かしています。
全部計算通りに並べると、プレイ感が硬くなるので、あえてランダム性を残しました。
2. 難易度は「隠し方」と「引き方」のセット
| 難易度 | ジョーカーの隠し | 引き方 |
|---|---|---|
| Beginner | 左端(ソート後) | 左端 or 右端 |
| Medium | 右端 | 中央付近 |
| Veteran | 履歴ベース | 完全ランダム |
「隠すのは賢いのに、引くのはランダム」という Veteran の組み合わせは、博打打ち(Gambler) とも呼べる個性になっています。
3. 人間プレイヤーも同じロジック
Human::organize_hand も joker_in_history_taken を使っています。
CPU だけの特権ではなく、プレイヤーも履歴の恩恵を受ける 設計です。
実際に遊ぶと、同じ番号ばかり入力していると、ジョーカーがその付近に移動していく — という挙動が見えてきます。
ジョーカーボーナス — ルールとの連動
このプロジェクトでは、複数ラウンド制のスコアリングも入れています。
ジョーカーを保持していたターン数の合計が最小のプレイヤーに +1 ボーナス が付きます。
履歴ベースの隠し方は、単なる演出ではなく 点数にも直結する ので、 「ジョーカーをいかに短く持つか」というババ抜き本来の緊張感とつながっています。

▲ 複数ラウンド終了後の Total Game Result。各ラウンドの獲得ポイント・ジョーカーボーナス・総合順位(🥇🥈🥉)が見える状態。
動かしてみる
git clone https://github.com/yoshitaka-k/old_maid.git cd old_maid cargo run
CPU 難易度は起動時に選べます。
1: Beginner グループ2: Medium グループ3: Veteran グループ
同じ位置ばかり引いてみて、手札整理後の挙動の違いを観察するのがおすすめです。
まとめ
ババ抜き CPU を作る過程で、次のような設計にたどり着きました。
- 引かれた位置の履歴 を
Playerに持たせる - 頻度順に並べて ジョーカーの移動先 を決める
- 難易度ごとに 隠し方と引き方 をセットで変える
- 履歴は完璧な最適解ではなく、シャッフルとの組み合わせで自然さを保つ
大規模な AI フレームワークは使っていませんが、
「相手のクセを記録する」というシンプルな仕組みだけで、
CPU との対戦に少しだけ 読み合い が生まれます。
次回予告
次の記事では、山札を切る ヒンズーシャッフル・リフルシャッフル・ディールシャッフル の実装と、
CPU 難易度ごとのパラメータ設計について書く予定です。
参考リンク
- old_maid リポジトリ
- Rust Strategy パターン: trait オブジェクト (
Box<dyn Trait>)