総会AIバトルを支える技術

こんにちは。普段はサーバーサイドJavaを書いている新卒プログラマの向井です。
弊社の社員総会の企画の一つとして社内のプロジェクト対抗で「ゲームAIバトル」を開催しました。 今回の記事では「ゲームAIバトル」を開催する上で利用した技術について紹介したいと思います。
社内向けイベントの企画のため、興味を持っていたが活用する機会がなかったGolangやDockerを活用する良い機会となりました。

ゲームルール

総会にはすべての職種の方が参加するため、ぱっと見で誰でも状況が理解できるゲームがネタとしては良いと考えていました。そこで、誰も知っているであろう「ボンバーマン」をベースのターンベースなコイン集めゲームで対決を行いました。ルールは以下のとおりです。

  • フィールド内に落ちているコインを集め、指定ターン数で集めたコインの数で競う
  • 爆弾で相手を攻撃でき、爆風をうけたAIは集めたコインの一部をフィールド内に落とす
  • ゲーム中に何度か巨大な爆弾が投下され、その爆風を受けるとすべてのコインを落とす

事前に各プロジェクトの代表を選出してお題のAIを組んでもらい、提出されたAIをバトルさせて結果を下記のようなボンバーマンのようなイメージの対戦動画を生成して、会場で実況しながら再生しました。

image02.png

出展: https://en.wikipedia.org/wiki/Super_Bomberman_4

AIバトル基盤の設計

各代表が得意な言語で自由に開発に挑んでもらいたかったため、AIバトル基盤は、AIの開発言語が特定の言語に依存しないよう、また実行環境のが特定の環境に依存しないように設計を工夫しました。

AIの入出力

AIは各ターンごとに、ターン情報(壁や爆弾、相手プレイヤーの位置などの情報)を入力とし、行動情報(そのターンにどこに移動し、爆弾を設置するか)を出力します。

シミュレータの設計

シミュレータは、それぞれのAIとやりとりしてゲーム進行を進行するプログラムです。AI開発者にはこのプログラムを渡して開発を行ってもらいました。
下記にシミュレータとAIのやりとりの流れを図示します。

image03.png

前述のとおり各代表が各々の環境で、好きな言語でAIを開発してほしかったため、シミュレータの実装には以下の点を意識しました。

  • AI開発が特定の言語に依存しないよう、より多くの言語が扱える方法でAIと通信を行う
  • 特別なランタイムがなくてもプログラムが動作する

まずより多くの言語が扱える通信方法として、AI側では標準入出力を、シミュレータ側ではパイプをつなぐことで通信を実現しました。(ソケット通信も検討したのですが、慣れてない人はこの辺が苦労するだろうと思い、標準入出力を採用しました。)
次に特別なランタイムを必要とせずシミュレータが動作するよう、Golangを用いてシミュレータを開発し、クロスコンパイル後のバイナリを配布することで特別なランタイムを必要としないプログラムを用意しました。

Golangによるシミュレータの実装

まずシミュレータは各AIを実行し、その標準入出力とパイプをつなぎます。GolangではCmd型のStdoutPipeStdinPipeという関数からそれぞれ標準出力と標準入力のパイプを操作することができます。型がio.ReadCloserio.WriteCloserなので、通常のファイルの読み書きと同様に扱うことが出来ます。

以下がサンプルコードになります。

// aiプログラムを実行し結果をやり取りするCmd型の値を取得
cmd := exec.Command(./ai)

// 標準入力を扱うパイプを取得
stdin, err := cmd.StdinPipe()
if err != nil {
	log.Panicln(err.Error())
}

// 標準出力を扱うパイプを取得
stdout, err := cmd.StdoutPipe()
if err != nil {
	log.Panicln(err.Error())
}

標準入出力とパイプをつなぐことができたら、ターン情報を送った後にAIから行動情報を受けとってゲームを進行します。この時、無限ループ等でAIが止まるとゲームの進行が阻害されるため、行動情報の受信にタイムアウトが必要になります。GolangではGoRoutineとChannelをうまく活用することで、タイムアウト処理を簡単に記述することが出来ます(参考: Go の並行処理)。

以下がタイムアウトを考慮した行動情報の読み取りの実装になります。

// タイムアウトを考慮した行動情報の受信ロジック
func (g *Game) receiveCommand(idx int) string {
	receiver := g.commandWorker(idx)
	for {
		select {
		case str := receiver:
			return str
		case time.After(time.Millisecond * time.Duration(Timeout)):
			return NoneCommandString
		}
	}
}

上記のように、C言語などで実装すると大変そうな処理がGolangだと簡単にかけるのでよいですね。

ゲームの進行を表示するCUIベースのビジュアライザの実装には、termboxを用いました。簡単にCUIベースのツールを作ることができて便利です。

image01.png

Dockerによる実行環境構築

各代表が提出したAIをシミュレータで安全に実行するためには、メモリやCPU・ネットワークの利用を適切に制限する必要があります。そこで今回は、AIの実行をDockerコンテナ内で行うことでAIをネットワークから隔離し、またメモリとCPUを制限しました。

Dockerを導入すると、シミュレータとAIの関係は下記のようになります。

image00.png

各AIごとにDockerfileを用意し、AIを実行するためのランタイムのインストールと、必要に応じてAIのコンパイルを行います。

以下に、C++のAIのDockerfileの例を示します。

FROM ubuntu14.04
RUN apt-get update -qq  apt-get -y install g++
RUN mkdir /app
WORKDIR /app
ADD . /app
RUN g++ ai.cpp -o /app/ai

このDockerfileをビルドすると、/app以下にAIプログラムのバイナリが配置されます。

あとは、以下のコマンドを実行することで、Dockerコンテナ内にあるAIが起動し、ホストOSから標準入出力を介してやりとりができます。

docker run --interactive=true --memory=512m --cpu-shares=1024 ai/player1 /app/ai

–interactive オプションをtrueにして実行することで、インタラクティブモードになります。先ほどのexec.Commandの引数に上記のコマンドを指定すると、シミュレータはDockerコンテナ内でAIを起動しているが、通信は今までどおりのパイプと標準入出力によって行えます。Dockerを用いることで先程の手順を変えること無く、容易にかつ安全にプログラムを実行することが出来ました。

プレイデータの再生

シミュレータにより出力されたプレイデータをより派手に演出を加えるために、Cocos-2dxを用いてプレイデータのプレイヤを実装しました。採用理由は社内で採用されているので、実装に困っても人に聞けるという単純な理由です。(Unityでも良かったのですが、新卒の中ではCocos-2dxに詳しい人の方が多かったため、Unityは不採用としました。)

まとめ

AIバトルの開催にあたって開発した基盤と、その技術について紹介しました。各開発者が得意な言語を用いて開発出来るように基盤の設計を工夫しました。開発者からも「楽しかった」などの感想をいただきました。

また、業務とは直接は関係ないが興味のあった技術に多く触れることができ、学びが多いイベントとなりました。