tl;dr
- ルール(Domain)とインプット(Interface)は分けて実装するべき
- Antigravityでの実装とシミュレーションのサイクルの高速化
- 運ゲーでは、どんなに頑張って戦略を作っても、その戦略を圧倒するような運がある
神ゲー「Flip 7」
Flip 7というボードゲームをご存知だろうか? このゲームをしている時の私は大抵歯ぎしりをしている。
「本当に神ゲーである!」
カウンティングさえできれば結構な勝率になれる単純な運ゲーであるが、実に、どういうわけか、面白い。
そして、筆者は最近負け込んでいる。なぜかは分からない。
そして、これらの理解不可能な面白さと敗北によって、筆者は囚われてしまった。
筆者は激怒した。必ず、かの 邪智暴虐の運を除かなければならぬと決意した。筆者にはボドゲ戦略がわからぬ。筆者は、スタートアップのソフトウェアエンジニアである。スクラムで開発し、Go言語で楽しく開発して来た。けれども勝利に対しては、人一倍に敏感であった。
…というわけで、Antigravityでシミュレーションのためのソフトを開発し、戦略を色々作り、検証し、そして、最適と思われる戦略を導き出した。
ゲームの紹介
何はともあれ、どういうゲームなのかを理解しておかねば話は始まらない。だが、Flip 7のルールはシンプルである。
- デッキからカードを引く、
- 引いたカードがアクションカード(Flip Three, Freeze, Second Chance)なら、その処理をする、
- 引いたカードがモディファイヤなら、自分の前に置く
- 引いたカードが数字なら、自分の前に置く、
i. もし同じ数字のカードを引いていたらバーストとなり、そのラウンドでの自分のプレイは終了、かつ、そのラウンドで取得した点数はプールされない
ii. 数字のカードを七枚引いたらボーナス点15点を得て、他のアクティブなプレイヤーを含めてラウンド終了 - 一枚目のカードを引く時以外、さらにカードを引くか、ステイを選択できる、
i. ステイした場合、数字のカードの合算が自分の点数となる、 - いずれかのプレイヤーが合計点数200点を超えることが確定したラウンドでゲームは終了し、ラウンド終了時に最も点数が高い人の勝利
デッキは全プレイヤーが共有して使い、その構成は次のようになっている。
- 数字のカードはその数字と同数の枚数のカードがある(例外として'0'は1枚ある):
- '0': 1枚、
- '1': 1枚
- '2': 2枚、
- ...、
- '11': 11枚、
- '12': 12枚
- モディファイヤは数字のカードの合計値に対して処理をする:
- '+2',...,'+10': それぞれ一枚ずつあり、その'+n'のnだけ現在のラウンドの点数に追加する
- 'x2': 現在のラウンド中、手にした数字の合計値に対して倍にする。他のモディファイヤの効果に対しては倍付けしない(e.g.,
[3, 11, +4, x2]の場合、(3 + 11)*2 + 4となる)
- アクションカード
- 'Flip Three': 自分自身を含むアクティブなプレイヤーから一人選び、そのプレイヤーに三枚ドローさせる、なお、その途中で'Flip Three'を再度引いた場合、現在のカードの処理が終了してから行う
- 'Freeze': 自分自身を含むアクティブなプレイヤーから一人選び、そのプレイヤーは現時点の点数を確保し、即時にラウンドから抜ける
- 'Second Chance': 一度だけバーストを回避する。なお、Second Chanceをすでに保持している状態で、さらにSecond Chanceを引いた場合、自分を除く他のアクティブなプレイヤーに渡さなければならない、対象がいない場合は捨て札に送る
説明が難しい?
プレイしてくれたまえ。
BGAというオンラインゲームプラットフォーム上にて、無料でプレイできる。
ついでに、以下のリンクからアカウント登録をしてくれると、筆者にギフトポイントが送られる。
(このギフトポイントというのは金にはならず、ただ単にプレミアム会員のトライアルがやりやすくなるだけなので、筆者にとっては得にならない。というのも、すでにプレミアム会員だからである。)
なぜシミュレーションをするか?
こんなクソ単純なゲームで、なぜわざわざシミュレーションをするのか、と読者は疑問に思うかもしれない。
筆者の答えは単純で、負け続けていたからである。
そして、自分が負け続けていることに納得がいかなかったからでもある。というのも、Flip 7はカウンティングが使えるのはルールの段階からも分かり、かつ、プレイの体験からも分かる。そして、友人らは別にカウンティングをしている様子はない。にも関わらず、私は負けている。
ルール上、均衡があるはずだ。なのに、明らかに負け越している。これには納得できない。ここには運の要素が絡むのは確かであるが、理解と納得は別である。
だから、筆者はその運の要素をなるべくなくすような、そして、統計上有利な戦略を作らねばならないと考えたのである。
筆者は激怒した。必ず、かの 邪智暴虐の運を除かなければならぬと決意した。筆者にはボドゲ戦略がわからぬ。筆者は、(以下略)
開発サイクル
翌週の友人とのボドゲの勝負に間に合わせるためには、なるべく早くプロトタイプを作って、実験して、シミュレーション結果から、新たな戦略を作る、ということをしなければならなかった。
そして、そのためには、実装スピード、シミュレーションの自動化と効率化、戦略の切り替えを容易にするアーキテクチャ、これらが必要であった。
そこで、筆者は
- プロトタイプの容易さのためのLLMの利用、
- ルールとインプットを分けるためのDDDの利用、
- データ駆動のし易い実行環境の利用、
- 適切なアーキテクチャ選択としてのデザインパターン、特にStrategyパターンの利用、
これらをサイクルとして組み込むことにした。
Happy Path prototype
データ駆動でサイクルを回していくにあたり、完成形を作るよりもプロトタイプを作ることが優先される。そこで、筆者はプロトタイピングとして、ルールの細かいところは無視して、まずはシミュレーションができるプログラムを作ること、そして、それが自動化・効率化される環境を作ることが急務であると判断した。そのため、実装方針を立てたら、最初から実装タスクに分解するよりかは、検証や学習に必要な最小限の要素が成立する状態にすることを選択した。いわゆる、ハッピー・パス・プロトタイプである。
ちょうど、Antigravityがリリースされたということもあり、それの検証も兼ねてインストールすることにした。
Antigravityで利用するのは、いわゆるVibeコーディングであり、エンジニアとしての筆者の苦手とするものであるが、自律的に駆動する存在に自らを検証させながらやるのは、高校教員の専修免許所有者としての筆者としては苦手ではない。
そこで、詳細は抜きに、ある程度のルールをAntigravityにコンテキストとして取り込ませ、検証したいことを教えたところ、あっさりと、ある程度回るものができた。
なお、Gemini 3.0のトークンは速攻で尽きるが、いつ復活するかは分かったので助かった1。
補足であるが、実装当初と違い、現在は課金体系が整い、Google AI Proを契約している筆者にとっては助かっている。
Stabilization via DDD
ある程度回るものができたとは言えども、それで終わりではない。戦略を自ら作るためには、継続的にディスカバリ、開発、検証、これらのサイクルを回していくことが必要であり、筆者もそれを期待していた。
そのためには、データの都合と実装の都合とを分けられておくようにするべきである。
ルールに従うことや、アクションを選択すること、これらは、いわゆる浅いモデルで扱うものではなく、深いモデル2を見つけられる構えをアーキテクチャとして提供されていなければならない。そのために、いわゆるドメイン駆動設計チックなものを採用することにした。なお、深いモデルと呼べるものは結局見つけることをできず、結局シミュレーションのための浅いものに終始してしまっているきらいがあるのは筆者としては恥ずべきものである。3
恥ずべきとは言っている一方で、いわゆるClean Architecture的なものはできており、後で述べる、ルールの誤解への対処も容易であった。
さらに、この選択は界隈で言われているような、Vibeコーディングでの無法図な、「良かれと思って」行われる過激な変更への対処ともなった、というのはここで述べておくべきであろう。
Data-Driven Optimization
Happy Path Prototype、DDDによる制御、これらの余計とも思えるが、しかしDeliberateな方法によって、開発の土台を作ることができた。
あとは、シミュレーションをぶん回していくだけである。
雑なバースト率計算のものをやって、ランダムとどれくらい違うか、という検証も、Antigravityを使うと、1万回の対戦シミュレーションが自動で実行され、その結果の統計までも資料としてまとめてくれることができる。
また、Heuristicに1ラウンドで何点を目指すのが勝率を高くすることができるのか、という、人間がやると七面倒な検証も、実際にぶん回して、あっさりレポーティングしてくれる。
さらに、次節で触れる、戦略の戦略とも言える、戦略の中のトリックをどれにするかということについても検証してくれる。
全くもって頭を使わず、データ駆動で最適化を図ることができたのである。
Architecture & Patterns
しかし、たとえ頭を使わずに済むとは言えども、頭を使わないためにはそれなりの工夫が必要である。
しかも、データ駆動で検証を重ねれば重ねるほど状況は刻々と変化していく。この状況の変化に対応するためには、可変性、もしくは再利用性の高さが必要である。
実際、Flip Threeの対象を選ぶにあたって、自分の点数を優先するか、他人をバーストさせるか、というのは、リアルのマッチであっても手に汗握るものである。そこで、どのようなバースト率の場合に自分を選ぶか他人を選ぶかのトリック(戦略の中の戦略)を埋め込める形にしなければならない。そして、シミュレーションの結果次第によっては、容易に実装を変更できなければならない。
そういうわけで、実装に際しては、デザインパターンを意識したものをタスクとして指令した。
いかにして戦略を実装したか
戦略を実装する上で、特に意識したデザインパターンは次の3つである。
- Strategy Pattern
- Composite Pattern
- Memento Pattern
これらについては、筆者より読者諸氏のほうが詳しいかと思われるため、GoFの「デザインパターン」に解説を譲り、本節ではどのように使ったのかを語ろうと思っているが、老婆心ながらこのAI・AGIの時代において重要な点を述べておく。
それは、協調関係を作ることである。過去の自分が作ったものの意図は不明瞭になることが多々ある。ましてや、多人数が共同作業を通して作られたものは、意図、命名、制約、いずれもが忘れられてしまう運命にある、いや、忘れられて新たな意味を作られていく運命にある。そういう時に、デザインパターンという、目的、動機、適用可能性、構造、結果、等をカタログとしてまとめられたものを用いるのは、ある種の束縛を与え、そして、ガードレールとなって新たな意味を作っていくものとしてくれる。これは、AI・AGIの文脈に置いても成立する。人間や時間という文脈においての協調関係は、大規模言語モデルとAgentの文脈に置いても協調関係となりうる。
それでは、いくつかパターンを取り上げていこう。
Strategy Pattern
戦略を作るにおいて欠かせないのは、Strategy Patternであろう。Strategy Patternというのは以下の目的をもつ。
アルゴリズムの集合を定義し、各アルゴリズムをカプセル化して、それらを交換可能にする。Strategyパターンを利用することで、アルゴリズムを、それを利用するクライアントから独立に変更することができるようになる。(GoF, p.335)
今回、筆者は、次のように実装した。
// internal/domain/strategy.go
// Strategy はAIプレイヤーの振る舞いを定義するインターフェース
type Strategy interface {
// Decide: デッキや手札の状況から、Hit(引く)かStay(降りる)かを決める
Decide(deck *Deck, hand *PlayerHand, playerScore int, otherPlayers []*Player) TurnChoice
// ChooseTarget: アクションカード(Freezeなど)を使う際、誰をターゲットにするかを決める
ChooseTarget(action ActionType, candidates []*Player, self *Player) *Player
// Name: 戦略の名前を返す(ログ出力用)
Name() string
}
例えば「慎重な戦略(CautiousStrategy)」では、バースト率が10%を超えた時点でStayするという実装になっている。
func (s *CautiousStrategy) Decide(deck *domain.Deck, hand *domain.PlayerHand, ...) domain.TurnChoice {
// ... (中略)
risk := deck.EstimateHitRisk(hand.NumberCards, hand.HasSecondChance())
if risk > 0.10 {
return domain.TurnChoiceStay
}
return domain.TurnChoiceHit
}
Composite Pattern
Flip Threeの対象をどう選ぶかというのは、戦略として重要である。単純に手札を見て選ぶか、ランダムに選ぶか、デッキから推測されるバースト率を元に考えるか、と色々ある。しかし、これらごとに新たな戦略を作ることはナンセンスである。
そこで、戦略の中の戦略として、トリックの採用は、Decoration Patternとしても実装可能であったが、これはstrategy extended functionality を用いることにした。
そこで、Composite Patternを使うことにした。Composite Patternの目的は以下の通りである。
部分-全体階層を表現するために、オブジェクトを木構造に組み立てる。Compositeパターンにより、クライアントは、個々のオブジェクトとオブジェクトを合成したものを一様に扱うことができる(GoF p.175)
筆者の実装は、厳密にはGoFのCompositeというよりはDelegationやAggregationの活用例に近いが、「攻撃的な戦略(AggressiveStrategy)」の実装を見てみよう。ここでは TargetSelector という別のインターフェースを構造体に埋め込んでいる。これにより、「いつ引くか」というロジックと、「誰を狙うか」というロジックを独立して組み合わせることができる。
// internal/domain/strategy/strategies.go
type AggressiveStrategy struct {
// TargetSelectorを埋め込むことで、ChooseTargetの実装を委譲(Delegate)する
TargetSelector
}
// コンストラクタで任意のTargetSelector(ランダム、リスクベース等)を注入できる
func NewAggressiveStrategyWithSelector(selector TargetSelector) *AggressiveStrategy {
return &AggressiveStrategy{
TargetSelector: selector,
}
}
// 実際のターゲット選択は、埋め込まれたselectorに丸投げされる
func (s *AggressiveStrategy) ChooseTarget(action domain.ActionType, candidates []*domain.Player, self *domain.Player) *domain.Player {
return s.TargetSelector.ChooseTarget(action, candidates, self)
}
Memento Pattern
シミュレーションをして、実際に友人とのゲームに投入するにあたり、前もって過去のログを引っ張り出して、レスポンス速度や使い勝手の確認をしようと思ったところ、自分が想定するよりもミスタイプが多いことに気づいた。そこで、前の状態に戻る欲求が湧いてきた。
Memento Patternの目的は以下の通りである。
カプセル化を破壊せずに、オブジェクトの内部状態を捉えて外面化しておき、オブジェクトを後にこの状態に戻すことができるようにする。(GoF, p.303)
筆者の実装の概略を示そう。
まず、ゲームの状態(スナップショット)を表す GameMemento と、履歴を管理する GameHistory を定義した。容量を節約するため、状態はJSON化した後にBase64エンコードして文字列として保持している。
// internal/application/manual_game_service.go
// GameMemento: ゲーム状態のスナップショット(Base64文字列)
type GameMemento string
// GameHistory: 履歴をスタックとして管理する
type GameHistory struct {
mementos []GameMemento
currentIndex int
}
// Push: 新しい状態を履歴に追加する(Undo後の分岐を考慮して以降の履歴は破棄)
func (h *GameHistory) Push(memento GameMemento) {
if h.currentIndex < len(h.mementos)-1 {
h.mementos = h.mementos[:h.currentIndex+1]
}
h.mementos = append(h.mementos, memento)
h.currentIndex = len(h.mementos) - 1
}
そして、実際のゲーム進行役である ManualGameService が、ターンごとにこの仕組みを利用する。
// Undo: ポインタを一つ前に戻し、その状態を復元して返す
func (h *GameHistory) Undo() (GameMemento, bool) {
if h.currentIndex > 0 {
h.currentIndex--
return h.mementos[h.currentIndex], true
}
return "", false
}
// Redo: ポインタを一つ進める
func (h *GameHistory) Redo() (GameMemento, bool) {
if h.currentIndex < len(h.mementos)-1 {
h.currentIndex++
return h.mementos[h.currentIndex], true
}
return "", false
}
なにが重要だったのか
ソフトウェア開発においては、痛みからパターンを学ばねばならない。
特に今回はシミュレーションでしかないが、ゲーム開発において、私が聞き及ぶところにおいては、ルールとインプットは分けるべきであるというのが常識であるそうだ。
特にそれを今回学んだのは、戦略の切り替えや、戦略の中でのトリックの使い分けをやる際である。それを実践するためには、デザインパターンの知見が重要であった、というのが振り返って思うことである。
実際、開発の初期段階では「ターゲット選択ロジック」が各戦略クラスの中に散乱しており、修正のたびに全てのファイルを書き換える必要があった。
そこで最初は CommonTargetChooser という共通クラスを作って共通化を試みたが、これでは「攻撃的な戦略(Aggressive)」であっても「ターゲット選択はランダムにしたい」といった柔軟な組み合わせに対応できないことが判明した。
この硬直した設計の痛みを経て、最終的に TargetSelector というインターフェースを切り出し、それを戦略に注入、DIをする現在の形(コンポジット/ストラテジーの組み合わせ)へとリファクタリングするに至ったのだ。
余談: AIレスバとシミュレーションの自動化
なお、シミュレーションという何度もやるようなものは、AIを活用して、ある程度自動でぶん回せるようにして、Strategyをバンバン追加して、それでお互いに競い合わせるのがよい。このあたりは、レスバ最強のAIをチューリッヒ大学が実装したところの話が参考になった4。
シミュレーション結果
さて、それでは、実際のシミュレーション結果を見てみよう。
結論としては、Adaptive戦略が現状での最適解となった。
各戦略の概要は以下の通りである。
- Cautious: バースト率がわずかでも上昇(例: 10%超)した時点で即座にStayを選択する
- Aggressive: バースト率がかなり高く(例: 30%超)なるまで引き続ける
- Probabilistic: 基本は数学的な確率に従うが、「相手との点数差」を見てリスク許容度を調整する
- Heuristic:「手札の合計が27点を超えたら止める」といった、人間が決めた固定の閾値(ルール)に従って機械的に判断
- Expected Value: 残りのデッキ構成を完全に記憶し、次の一枚を引いたときの「点数期待値」がプラスである限り引き続ける
- Adaptive: 通常はExpected Valueとして最強の振る舞いをするが、誰かが「上がり(200点)」に近づくと、なりふり構わぬAggressiveへと移行
これらのシミュレーション結果は以下の通りである。
ソロプレイでの200点に達するターン数:
| Strategy | Avg Rounds | Median Rounds |
|---|---|---|
| Adaptive | 9.88 | 10.00 |
| ExpectedValue | 9.92 | 10.00 |
| Heuristic-27 | 9.95 | 10.00 |
| Probabilistic | 9.88 | 10.00 |
| Aggressive | 11.24 | 11.00 |
| Cautious | 12.37 | 12.00 |
複数プレイヤーでの勝率:
| Strategy | 2 Players | 3 Players | 4 Players | 5 Players |
|---|---|---|---|---|
| Adaptive | 19.45% | 19.65% | 19.90% | 19.83% |
| ExpectedValue | 18.30% | 19.90% | 19.15% | 22.15% |
| Heuristic-27 | 15.30% | 18.00% | 20.35% | 19.83% |
| Aggressive | 19.50% | 19.95% | 18.10% | 18.55% |
| Probabilistic | 17.30% | 16.40% | 18.15% | 14.73% |
| Cautious | 10.15% | 6.10% | 4.35% | 4.90% |
各戦略同士の勝率:
| vs | Cautious | Aggressive | Probabilistic | Heuristic-27 | ExpectedValue | Adaptive |
|---|---|---|---|---|---|---|
| Cautious | - | 34.45% | 22.50% | 25.85% | 21.35% | 22.30% |
| Aggressive | 65.55% | - | 46.25% | 47.35% | 43.40% | 43.85% |
| Probabilistic | 77.50% | 53.75% | - | 48.65% | 45.60% | 45.75% |
| Heuristic-27 | 74.15% | 52.65% | 51.35% | - | 48.15% | 49.15% |
| ExpectedValue | 78.65% | 56.60% | 54.40% | 51.85% | - | 49.95% |
| Adaptive | 77.70% | 56.15% | 54.25% | 50.85% | 50.05% | - |
結局勝てるようになったの?
結局、勝つかどうかが重要なのであるが、友人らと6戦ほどした結論として、
1勝5敗
である。
確率を無視して、引きですべてが決まってしまうところがあるので、これはしょうがないといえばしょうがないのであるが、いや、悔しい。
ゲームのログを取りつつ、もっといい方法の戦略がないか、運の強いプレイヤーを実力でねじ伏せる方法がないかを模索する。
オレはようやくのぼりはじめたばかりだからな
このはてしなく遠いボードゲーム戦略坂をよ…
技術的改善点
マニュアルモードの実装はクソほどバグだらけで大変だった。
ここらへんはKiroなどを使ってSpecから作るのがよかっただろう。
リファクタ系の実装については、複数のAIを用いて実装し、別のAIで判定するという脳死開発サイクルでも十分効果はあった。
- Issue: https://github.com/2222-42/flip7_strategy/issues/64
- PR by Copilot: https://github.com/2222-42/flip7_strategy/pull/65
- PR by Antigravity: https://github.com/2222-42/flip7_strategy/pull/66
Repository
まだ戦略や実装ではあまいところがある。この記事を読んで、興味を持った人は是非、Flip 7をやってほしい。そして、筆者と同じように、このゲームに取り憑かれ、戦略を考え、シミュレーションを行い、最適な実装をしたいという人は、是非Contributionをするか、Forkして自分の戦略を作ってほしい。
もしかしたら、筆者がとうとう飽きて、別のゲームにお熱になるかもしれない。しかし、その場合もおそらくはまた、今回のように、戦略をシミュレーションするかもしれない。だが、今度はきっと、もっと上手に失敗するであろう。そして、また挑戦するであろう。
おまけ
筆者は、普段は毎週Newsletterを送っている。
話題は多岐に渡るが、最近は、Philosophy of Software ArchitectureやPhilosophy of Data Modellingの可能性を探っている。
そして、そこでの文章は、紙とペンを用いた手書きをまずやることにしている。Yellow Legal Padに、万年筆で、文章を書いて、赤ペンで修正し、それをタイプし、声に出して読んで、そして、Newsletterを送る。これを毎週繰り返している。
この記事については、手書きではないが、実際にタイピングしながら行っている。AI、LLMを活用した開発の話をしているのに、この文章はAIを活用していないのか、と疑問に思うかもしれない。
が、筆者はMore 5% Psychoルールがある。自分が思っているよりも5%サイコになれ、というルールである5。AIは文章に関しては穏やかなのか尖っているのかよくわからない文章になることが多く、筆者のMore 5% Psyhoルールを満たすものが作れない。これは教育次第、プロンプト次第、と言えばそうなるのかもしれないが、もう一つ重要なのは、Writing is Thinkingであることである。書くことは考えることであり、この書いた結果を人の思考に伝播させる、Prompt Injectionを自らしたいのである。主体性の問題である6。
そんなMore 5% Psychoで、手書きで毎週書いている、Newsletterは下記からSubscribe可能である。
Gentleman Philosopherの思考を追いかけたい人は是非、検討してほしい7。
-
なお、こんなことのために、貴重な天然資源を浪費するのは気が引けなかったわけではない。この課題や、意味を問うこと自体が意味を毀損させる可能性については、「人生の意味を問うことの意味と危険性」という題で、Newsletterで話している。 ↩
-
深いモデルという言葉はエヴァンスのものによる。詳細はエヴァンス氏の本や杉本氏の本に譲るとするが、軽く知りたい人は「データモデリングでドメインを駆動する」の勉強会の報告にも取り扱われているので、そちらを閲覧してほしい。 ↩
-
深いモデルにまで至れなかった、というのはある一方で、ゲームという自分たちの思考を取り扱うものをモデルにしたこと、そして、戦略を作り、戦略をスイッチし、戦略を検証する、というのは、我々が計算機上で何かをすることよりも多くのことを我々がメンタルモデルとして、データモデルとしてやっていること、現実世界の射影ではなく思考の上でやっていることをモデルにしているということでドメイン駆動であり、また、私がシミュレーションとしてやっていたことはまさにドメイン駆動なのかもしれない。なので、そこまで恥ずべきことではないのかもしれない。いや、しかし、大仰な言葉を使うには、やはり恥ずべきかもしれない。 ↩
-
r/ChangeMyViewというサブRedditでの事件である。Redditとアカデミック、両方から非難があり、非常に難しい問題であるが、この強烈な事件は、筆者の今回の実装における参考となった。 ↩
-
絵描きの文脈では、「世に出していいと自分で思っている癖のもう一段階上の癖を出せ」という考え方もあるだろう。 ↩
-
主体性の問題についても、「人生の意味を問うことの意味と危険性」で若干触れている。 ↩
-
検討するのが面倒な人は、これまでのArchiveを一部公開しているので、そちらを閲覧してみればいいと思う。ただ、筆者は自らの記事を非公開にすることがあるし、だいたい公開するのも遅い。メールは一度送って受信してしまえばそういうことはない。待つことは思ったよりもコストが高い。 ↩