こんにちは、やまだたいし( https://twitter.com/OrotiYamatano )です。
AI、流行っていますよね。
とくにコンテンツ作成の分野では、AIを使うことが当たり前になりつつあります。
一方で、ゲーム内でAIを活用している事例は、まだそれほど多くありません。
少なくとも、既存ゲームに本格的に組み込まれているケースは限定的に感じます。
なぜゲーム内AIの導入は進んでいないのか。
その疑問を晴らすべく軽く使ってみたので、その検証結果をブログにまとめました
本記事は2025年ギルドアドベントカレンダーに投稿予定だったブログです。
後編はこちら orotiyamatano.hatenablog.com
目次
本記事の対象者
本記事は、以下のような読者を想定しています。
Unity を用いたゲーム・ツール開発の経験がある ローカル環境で AI を動かすことに関心がある
一方で、
商用利用を前提とした最適解 を提供するものではありません。
本記事の目的は、
Unity 上で SLM を実際に動かした場合、 どの程度の環境・労力で、どこまで出来るのか を、実装・失敗・所感をまとめた上でソレが誰かの役に立てばな、というぐらいの記事です。
今回やったこと:AIが心理戦を仕掛けてくる「じゃんけん」
実際問題AIってどのくらい使えるの?
ゲームに使えるの?
って思ったのが理由です。
では、なぜ「じゃんけん」なのか。 それはルールがこれ以上なくシンプルで、
かつ「相手の裏を読む」という心理戦の要素が強いからです。
AIの意思決定が結果に直結するため、AIの「賢さ」や「ボロ」が一番見えやすい題材だと判断しました。
記事は前半と後半の二篇となります。
前半はSLMを使ったゲームの実装についてAIの中身編と称しその中身の解説とか。
後半はAIコーディングツールを使ったことについて所感など。
まずは前編、じゃんけんゲームの実装についてお話しします。
動作環境
AIを使ったゲームを作るにあたり
以下の技術・ツール群を利用しました。
使用モデル
SLM:Phi-4 mini(ONNX)
推論ランタイム:ONNX Runtime GenAI
ハードウェア・開発環境
OS:Windows 11 64bit CPU:AMD Ryzen 7 8845HS メモリ:16GB GPU:GeForce 4060 Laptop GPU(未使用) Unity Editor:6000.0.62f1(途中でUnity6000.3に) 検証形態:Editor 上での動作確認
なぜ LLM ではなく SLM を選んだのか
今回の検証では、ChatGPT などの LLM(Large Language Model)は使用していません。
理由は、ゲーム内での利用を考えたときに現実的ではないと感じた点が複数あったためです。
具体的には、以下の点が気になりました。
- 利用料金が高い
- 速いものを使おうとすると、さらにお金がかかる ユーザーが遊べば遊ぶほど、開発者の財布からAPI利用料が消えていくモデルは、買い切り型ゲームや小規模な運営タイトルではリスクが大きすぎ
- ネットワーク越しに AI を利用することへの不安
(開発を任せるステークホルダーや、実際にゲームをプレイするユーザーの双方) 情報漏洩リスクやGPL汚染の問題(ほぼ同一の内容をコード内に生成しGPL汚染する) - 外部サービスに強く依存している感覚がある
「外部サービスの仕様変更で、ある日突然キャラクターの性格が変わった」「サービス終了でゲームが動かなくなった」という事態は、開発者として避けたい。
性能の高い AI を使うこと自体は可能ですが、
その前提として、コスト面や挙動の面で不安定さを感じるサービスに依存する必要があるように思えました。
一方で、SLM(Small Language Model)であれば、
- ローカル環境で動作させられる
- 大規模な演算装置を必要としない
という特徴があり、
まず試す対象としては現実的ではないかと考えました。
もちろん SLM は万能ではありません。
そのため今回は、昔の AI と比べてどの程度使えるようになっているのか、
という点の検証も兼ねて、この選択をしています。
Phi-4 mini の選定理由
SLM を使う方針を決めたあと、次に考えたのがどのモデルを選ぶかです。
候補としては、Llama 系、Gemma、Qwen、Phi 系など、いくつか選択肢がありましたが、
今回はMicrosoftの Phi-4 mini を選択しました。
選定理由は、
です。
理由は単純明快。「Microsoft製だから、C#/.NET周りの導線がしっかりしている」です。
AI業界はどうしてもPython中心に回りがちですが、ゲーム開発者としてはC#で使いたい。
Pythonの環境構築に頭を悩ませることなく、NuGetやライブラリを通じてスムーズにUnityへ組み込めること。
これが今回の検証において、何よりも優先すべき「手離れの良さ」でした。
また、Phiにもいくつかありますが
Phi-4 mini はパラメータ数が約 3.8B と、
SLM としてはやや大きめですが、
- 家庭用端末で動かせること
- CPU 実行を前提にできること
を考えると、
今回の検証目的にはちょうど良いサイズ感だと判断しました。
今回は使用モデルは CPU 実行版を利用しています。
GPU 実行版のモデルも利用可能ですが、将来的にスマートフォンや、
GPU リソースをグラフィックスに全振りしたいゲームへの組み込みを想定しています。
私は既存のAIも残りつつ、みなさんの周りには小さなツールとしてAIが残ると思っています。
企業で使うPCとみなさんが使うスマホのような位置づけでしょうか。
大きな企業では相変わらずAIを使うけれど、身の回りのタスクなどやちょっとした家具家電にAIが搭載されると思います。
そこで現実的なのがCPUってわけです。
ONNX Runtime GenAI 選定理由
Phi-4 mini を Unity 上で動かすにあたって、
次に考える必要があったのが 推論ランタイムの選定 です。
今回は、ONNX Runtime GenAI を使用しています。
Unity から C# で扱える形で、現実的に選択できるランタイムだったためです。
Phi-4 mini は ONNX 形式で提供されており、
ONNX Runtime を使えば、
という前提を崩さずに検証ができます。
Unity で AI を扱う場合、
外部プロセスを立てたり、
別言語のランタイムと常時通信する構成も考えられますが、
今回はそこまで複雑な構成を取りたくありませんでした。
あくまで、
- Unity Editor 上で動かせること
- 実装と検証を素早く回せること
を優先しています。
その点で ONNX Runtime GenAI は、
- 生成系 AI 向けの API が用意されている
- ONNX Runtime をベースにしており、情報が追いやすい
先述の通り、C#で完結させたかったのでランタイムも
今回の検証目的に合っていると判断し
ONNX Runtime GenAI を採用しています。
じゃんけんAIゲームについて
今回作成したのは、
AIと会話しながらじゃんけんを行うシンプルなゲームです。
いきなり複雑なゲームに AI を組み込むのではなく、
ルールが単純であること
勝敗が明確であること
AIの意思決定が結果に直結すること
を重視し、題材としてじゃんけんを選びました。
じゃんけんであれば、
プレイヤーの行動(グー・チョキ・パー)
勝敗の履歴
会話の流れ
といった情報を元に、
AIの振る舞いを観察しやすいと考えたためです。
ゲームの基本的な流れは以下の通りです。
ゲームの実装
プレイヤーが「じゃんけんの手」を出した場合
以下の情報を入力として、
AI が次に出すじゃんけんの手を決定する- これまでの会話内容
- ユーザーのメッセージの過激度
- AI が保持しているモノローグ
勝敗結果を判定し、
その結果を元に AI がモノローグを追加生成する勝敗結果に応じて、
ユーザーの過激度などの内部パラメータを更新するこれまでの勝敗履歴、モノローグ、会話内容をまとめて
AI に渡すAI が会話を生成する
プレイヤーが「メッセージ」を送った場合
メッセージ内容を元に、
AI がモノローグを追加生成するユーザーの過激度などの内部パラメータを更新する
AI が会話を生成する
じゃんけんの勝敗と会話は完全に独立しているわけではなく、
プロンプトの一部として組み込むことで、
結びついている構成になっています。
このように内部的に AI のモノローグ(心理状況)を別途持たせることで、
心理戦らしさを演出できるのではないかと考えました。
AIのコア部分実装
せっかくなので組み込み方法についても解説しておきます。
といっても組み込み自体は難しくなく、問題があるとするなら公式の資料が少し古くなっていることです。
根本は難しくなく基本的に元となるReferenceを読めば大体わかると思います。
まず始めにモデルとなるaiのコアであるPhi4 miniをインストールします。
たしか私はコチラからダウンロードしました。
CPUで動かすかGPUで動かすかなどもこのモデルの情報により決定されます。
NuGetForUnityなどを使いONNX Runtime GenAIを入れていきます。
入れることで諸々のパッケージがインストールされます。

なお、ONNX Runtime GenAI を NuGet 経由で導入した際、
Unity に同梱されている .NET 系 DLL と競合し、
エディタ起動時にエラーが発生しました。
当時は NuGet 側で導入された一部 DLL を削除することで回避できましたが、
具体的な DLL 名や API Compatibility Level までは記録していません。
本記事執筆時点では再検証していないため、
導入時には Unity のバージョンや環境差分にご注意ください。
実装の核となるのは、UniTaskを使って別スレッドで推論を回し、結果をパースする部分です。
using System; using System.Linq; using System.Text; using System.Threading; using System.Threading.Channels; using Cysharp.Threading.Tasks; using Microsoft.ML.OnnxRuntimeGenAI; namespace AiRockPaperScissors.Domain { /// <summary> /// Phi による推論ロジックのみを提供するユーティリティクラス。 /// Model や GeneratorParams の設定ポリシーは呼び出し側で管理する。 /// </summary> public static class PhiExecutor { private static readonly string[] StopMarkers = { "<|end|>", "<|user|>", "<|system|>", "<|assistant|>", }; private const int TailWindowSize = 128; internal sealed class InferenceResult { public InferenceResult(string text, int promptTokens, int generatedTokens) { Text = text; PromptTokens = promptTokens; GeneratedTokens = generatedTokens; } public string Text { get; } public int PromptTokens { get; } public int GeneratedTokens { get; } public int TotalTokens => PromptTokens + GeneratedTokens; } public static UniTask<string> InferFullAsync(Model model, string prompt, CancellationToken ct) { return InferFullAsync( model, prompt, ct, genParams => { genParams.SetSearchOption("max_length", 256); genParams.SetSearchOption("min_length", 0); genParams.SetSearchOption("temperature", 0.7f); genParams.SetSearchOption("top_p", 0.9f); genParams.SetSearchOption("repetition_penalty", 1.1f); }, maxGeneratedTokensOverride: 64); } public static async UniTask<string> InferFullAsync( Model model, string prompt, CancellationToken ct, Action<GeneratorParams> configure, int? maxGeneratedTokensOverride = null, System.Threading.Channels.ChannelWriter<string> streamWriter = null) { var result = await InferFullWithStatsAsync( model, prompt, ct, configure, maxGeneratedTokensOverride, streamWriter); return result.Text; } internal static async UniTask<InferenceResult> InferFullWithStatsAsync( Model model, string prompt, CancellationToken ct, Action<GeneratorParams> configure, int? maxGeneratedTokensOverride = null, System.Threading.Channels.ChannelWriter<string> streamWriter = null) { return await UniTask.RunOnThreadPool(async () => { Exception caughtException = null; try { ct.ThrowIfCancellationRequested(); using var genParams = new GeneratorParams(model); configure?.Invoke(genParams); using var tokenizer = new Tokenizer(model); using var tokenStream = tokenizer.CreateStream(); using var generator = new Generator(model, genParams); using var sequences = tokenizer.Encode(prompt); generator.AppendTokenSequences(sequences); var promptTokens = generator.GetSequence(0).Length; var generatedTokens = 0; var maxGeneratedTokens = maxGeneratedTokensOverride.GetValueOrDefault(-1); var generatedTextBuilder = new StringBuilder(Math.Max(256, prompt.Length * 2)); var tailWindow = string.Empty; while (true) { ct.ThrowIfCancellationRequested(); if (generator.IsDone()) { return CreateInferenceResult( generatedTextBuilder, promptTokens, generatedTokens); } generator.GenerateNextToken(); var generatedTokenIds = CopyTokens(generator); if (generatedTokenIds.Length == 0) { return CreateInferenceResult( generatedTextBuilder, promptTokens, generatedTokens); } foreach (var val in generatedTokenIds) { if (maxGeneratedTokens > 0 && generatedTokens >= maxGeneratedTokens) { return CreateInferenceResult( generatedTextBuilder, promptTokens, generatedTokens); } var decodedToken = tokenStream.Decode(val); generatedTokens++; generatedTextBuilder.Append(decodedToken); if (streamWriter != null) { await streamWriter.WriteAsync(decodedToken, ct); } if (decodedToken.IndexOf('<') < 0) { continue; } tailWindow += decodedToken; if (tailWindow.Length > TailWindowSize) { tailWindow = tailWindow[^TailWindowSize..]; } if (StopMarkers.Any(mark => tailWindow.Contains(mark, StringComparison.Ordinal))) { return CreateInferenceResult( generatedTextBuilder, promptTokens, generatedTokens); } } } } catch (Exception exception) { caughtException = exception; throw; } finally { streamWriter?.TryComplete(caughtException); } }, cancellationToken: ct); } private static InferenceResult CreateInferenceResult( StringBuilder generatedTextBuilder, int promptTokens, int generatedTokens) { var text = generatedTextBuilder .ToString() .Replace("<|end|>", string.Empty) .Replace("<|user|>", string.Empty) .Replace("<|system|>", string.Empty) .Replace("<|assistant|>", string.Empty) .Trim(); return new InferenceResult(text, promptTokens, generatedTokens); } private static int[] CopyTokens(Generator generator) { return generator.GetNextTokens().ToArray(); } /// <summary> /// 指定されたテキストのトークン数を計測する。 /// </summary> public static int CountTokens(Model model, string text) { if (string.IsNullOrEmpty(text)) { return 0; } using var tokenizer = new Tokenizer(model); using var sequences = tokenizer.Encode(text); using var genParams = new GeneratorParams(model); genParams.SetSearchOption("max_length", 256); using var generator = new Generator(model, genParams); generator.AppendTokenSequences(sequences); return generator.GetSequence(0).Length; } } }
モデルの読み込みは StreamingAssets から行う
var modelDir = $"{UnityEngine.Application.streamingAssetsPath}/Models/cpu-int4-rtn-block-32-acc-level-4/";
_model = new Model(modelDir);
Phi系は公式にあるとおりプロンプト規則を文書化する必要があります。(公式の参照モデルカードはphi3だけどほぼ同じ)
このモデルの形式は Huggingface モデル カード に記載
このようにプロンプトの最後をチェックしなければ、ユーザーのセリフまで動的に生成してしまったり、プロンプトのシステムメッセージを誤読したりしてしまい精度が低くなる。
ちなみにこれが別スレッドで動かしているのはAIが思考中に画面が硬直化してしまうため。
その対応策。
ざっと出しましたが、簡単にいうと PhiExecutor.InferFullWithStatsAsync のprompt が
AIとしてのメッセージと過去のやり取りどっちも含まれた文字列群で、
下のgenParamsは現状のAIに対する設定値です。
じゃんけんの手を出してくださいのような命令プロンプトと過去のじゃんけん結果や、モノローグを考慮にじゃんけんの手を確定させる。
AI側がPaperなどと発言するのでその文字列をパースし該当の手を出したと確定させる。
という流れです。
正直しっかり作りきらず途中でうち切っていたりするので
トークンオーバーになったりしますが、とりあえず今回は検証用として許容し公開しました。
実際にじゃんけんするようすを動画でみてみましょう。
検証動画
検証の結果:SLMは組み込んでゲームに使えるのか?
で、結局どうだったかというと、正直「よくわからない!」というのが答えでした。
会話面がとにかく統一感がない。
グーを出したことを「岩手(Rockだから?)」と言ったり、パーを「ペーパー」と呼んだり日本語と英語が入り混じったような内容になっていました。
こうなると、AIが心理戦としてブラフを読んできたのか、それとも単に偶然そうなっただけなのかがさっぱり判別できません。
「戦略的に動いているな」という手応えは、残念ながら今回の検証では得られませんでした。
おそらく、プロンプトを英語に集約しきれなかったのが、
挙動の不安定さにそのまま出ちゃったかな、という感じです。
以前も言った通り、SLMに高度な意思決定を丸投げするのはまだ荷が重そうに感じました。
内部パラメータを更新して、それに連動する文章を裏で選ばせる……といった、
「思考ロジックの中核」として使うのが今のところは現実的かなと思いました。
とはいえ、組み込むことは可能でした。
GPUを使うことでパソコン等なら早い処理を見込めると思えるので、
今後このような使い方は増えていくのではないかなと感じさせてくれた実験内容になりました。
まとめ
AIを直接触るのは高コストだが、今後ローカルSLMは使うことがかなり増えてきそう。
今回は日本語特化モデルではなかったのと面白さを出すために何度も思考させ
CPUで動かしたため思考速度が遅くなったがGPUを使えば比較的に早く解答が出てくるはずだ
またプロンプト調整などするとハンターハンターのグリードアイランドのNPC並のCPUは作れそうだなぁと思いました。

次の記事はAIコーディングツールを使ったことについて所感です。
よろしくお願いします。