はじめに
リアルタイムで麻雀牌を認識し、自動で点数計算まで行う個人開発アプリ「麻雀サポーター」に、生成AI(Gemini)による対局アドバイス機能を追加しました。
オンデバイス画像認識 × Firebaseバックエンド × Gemini API
という構成で実装しています。
本記事では、そのアーキテクチャと設計思想を解説します。
アプリ紹介
「麻雀サポーター」は、麻雀初心者向けのアプリで、初心者でも楽しくリアル麻雀ができるようにAIでサポートしたいという思いで開発したアプリです。
手牌にカメラをかざすだけでAIが麻雀牌を認識し、自動で以下のようなサポートをしてくれます。
これらの機能を実戦で使えるようにリアルタイムカメラ、かつリアルタイムレスポンス、かつ直観的で操作レスなUIでお使いいただけます。
新機能「AIサポート」リリース
今回はこのアプリから「Gemini」をAPIで呼び出すことにより、
- 今テンパイ中だけど立直すべき?
- このままダマで待ってれば手替わり期待できる?
- 受け入れ多い打牌は分かったけど、じゃあ実際何切るべき?
のような実戦で湧いてくる疑問への回答を、生成AIに作ってもらう機能を追加しました。
実際の画面

右側の「AIサポート」のアイコンをクリックし、その状況に応じた質問事項一覧が表示されるので、気になる質問をタップします。
そうすると数秒でGemini AIから回答が返ってきます。
新機能リリースに至った経緯
麻雀は、深く思考するゲームです。
手牌や場況のパターンが何通りもあり、その中からユーザは常に最適な判断を求められます。
本アプリの画像認識モデルは、麻雀牌を正確に認識することはできますが、状況に応じた思考までは行えません。
本アプリの打牌候補算出アルゴリズムは、受け入れ枚数の多い打牌を提示できますが、場況を踏まえた最適解までは導けません。
実際には、「受け入れ枚数が多い=最適な打牌」とは限らないのが麻雀というゲームです。
そこで、状況に応じた推論や判断を担う存在として、汎用的な生成AIは非常に相性が良いと考えました。
これまでの“固定的”なサポート機能を拡張し、より“柔軟”に対応できる麻雀アドバイス機能を生成AIで実現しよう、という結論に至りました。
生成AIにやらせないこと
とはいいつつも、生成AIは万能ではありません。
今回のアプリでは「やらせること」と「やらせないこと」を明確に分離しました。
特に第一に以下の案が浮かびました。
麻雀牌の検出さえも生成AIでできるのでは?
しかしそれについては、現時点では生成AIを使うことのデメリットの方が大きいという判断から、これまで通りアプリ搭載の特化モデルにやらせることとしました。
- リアルタイム性を損ないたくない
→ APIで生成AIに判断させる方式では、どうしてもネットワーク遅延が大きくなり、このアプリの強みであるリアルタイム性を大きく損なうと思いました。 - ユーザのカメラデータをネットワークで送信したくない
→ プライバシー的な話で、カメラで映したデータはユーザのデバイス内に閉じておきたい思いが強くありました。 - 麻雀牌のような似通った物体同士の画像認識については、生成AIはまだ不得意
→ 実際に麻雀牌の画像を入力してみるとわかりますが、結構な確率で外します。細かい画像認識は特化モデルに全然及びません。
以上より、今回は推論色や思考色の強い立直判断や何切る判断の材料としての使用が最適と判断しました。
技術・アーキテクチャ
クライアント(Androidアプリ)
- フレームワーク : Flutter 3.32.8
- 言語 : Dart 3.8.1
- 画像認識 : TensorFlow Lite 0.12.1
- モデル : SSD MobileNet V2 FPNLite 640x640
バックエンド(Firebase)
- アプリ認証 : Firebase App Check
- ユーザ認証 : Firebase Authentication(匿名ログイン)
- API : Firebase Cloud Functions(TypeScript)
- DB : Firestore Database
生成AI
- モデルプロバイダー : Google Gemini
- 使用モデル : Gemini 2.5 Flash
構成図
本アプリは基本的な機能については、全てオンデバイスで動作可能です。
麻雀牌検出モデルもアプリ内に内蔵されており、アルゴリズムもすべてデバイス内で完結します。
ユーザが生成AI機能を使用した場合のみ、バックエンドを経由してGemini APIへアクセスします。
設計のポイント
今回の設計のポイントは、
- ユーザごとのリクエスト量の厳格な制御
- ユーザの匿名ログイン
- セキュアなAPI呼び出し(App Check)
の3点です。
ユーザごとのリクエスト量の厳格な制御
生成AIをAPIから使用できるようにするうえで必ず考えなければならないことです。
Geminiは従量課金。無作為に使われすぎるのは困ります。
本当は有料化したいところですが、それでは到底使われないと思います。
ですので需要が見込めるまではコストを重視した軽量モデル(Gemini 2.5 Flash)を使用し、無料で提供することにします。
月々の予算とそのモデルの課金額を照らし合わせ、許容できる最大のリクエスト量と想定ユーザ数に対するユーザごとの内訳を作ります。
例えば本アプリであれば1ユーザごとの月間の最大リクエスト量は50に制限しています。
また、全ユーザのアクセスについて、Geminiのレート制限にも引っかからないように気を付ける必要があります。
Geminiの1分あたりのレート制限に引っかからないように、本アプリではユーザあたりの1分間の最大リクエスト数は3に制限しています。
データベースでのリクエスト量の管理
DBのusersコレクションには以下のようなレコードを設定します。
- lastMinuteResetAt : timestamp
- lastMonthlyResetAt : timestamp
- minuteCount : number
- monthlyCount : number
Cloud Functionsでの制御
コード上では、まずリクエスト受信時の現日時が制限解除のタイミング「lastMinuteResetAt
」や「lastMonthlyResetAt」を超過しているかを確認し、それに応じた更新処理を行います。
// userDataはDBのusersコレクションの参照
if (now < userData.lastMinuteResetAt) {
// 制限解除のタイミングの前なので、そのままカウントアップ
userData.minuteCount = (userData.minuteCount ?? 0) + 1;
} else {
// 制限解除のタイミング超過しているので、カウンタ初期化&次の制限解除日時へ
userData.minuteCount = 1;
userData.lastMinuteResetAt = admin.firestore.Timestamp.fromMillis(
// 現日時から60秒後
now.toMillis() + 60 * 1000
);
}
if (now < userData.lastMonthlyResetAt) {
// 制限解除のタイミングの前なので、そのままカウントアップ
userData.monthlyCount += 1;
} else {
// 制限解除のタイミング超過しているので、カウンタ初期化&次の制限解除日時へ
userData.monthlyCount = 1;
userData.lastMonthlyResetAt = admin.firestore.Timestamp.fromDate(
// 現日時から1か月後
nextMonth
);
}
その後、「minuteCount」と「monthlyCount」を監視し、これが最大値を超過していたらGeminiへのアクセスは中止とします。
if (userData.monthlyCount > MONTHLY_LIMIT) {
// 制限超過時の処理
return { type: "MONTH_LIMIT", resetAt: userData.lastMonthlyResetAt.toMillis() };
}
if (userData.minuteCount > MINUTE_LIMIT) {
// 制限超過時の処理
return { type: "MINUTE_LIMIT", resetAt: userData.lastMinuteResetAt.toMills() };
}
当然ですが、これらの処理は整合性を保つために、Firestore Transactionを用いて更新します。
await db.runTransaction(async (tx) => {
// リクエスト数監視・制御・更新処理
}
また想定したユーザ数以上の使用があったり、DoS攻撃があった際に備え(対策済みであるものの)、システム全体でのリクエスト管理も行います。
全体でのリクエスト数をカウントしておき、それが予算に収まる許容量を超過する場合、一時サービス停止とし、Functionsの最上部で処理を中止します。
これらは毎月1日に解除されます。
例えばFunctionsでは以下のようにトリガできます。
export const resetMonthlyCount = onSchedule(
{
region: "asia-northeast1",
schedule: "0 0 1 * *", // 毎月1日 00:00
timeZone: "Asia/Tokyo",
},
async () => {
try {
await db.collection("system").doc("global").set({
monthlyCount: 0
}, { merge: true });
console.log("system 月次カウントリセット完了");
} catch (e: any) {
console.error("system 月次リセットエラー:", e);
}
}
);
ユーザの匿名ログイン
クォータ使用量の制御にユーザ管理が必要となるとはいえ、ツールとして「使いやすさ」を強みとしている本アプリでログインを強いることは絶対にしたくありませんでした。
そこで、Firebase Authenticationの「匿名プロバイダ」を使用しました。
本機能を使用することで、ユーザはアカウント登録、およびログイン操作をすることなくアプリから匿名ユーザとしてログイン可能になります。
例えばクライアント側のFlutterからは、以下のような記載だけでユーザにIDが割り当てられます。
// 匿名ログイン
User? user;
try {
// FirebaseAuthライブラリを使用し現在のユーザ情報取得
// 1回でも匿名ユーザでログインしていれば、デバイスに情報が残る
user = FirebaseAuth.instance.currentUser;
if (user == null) {
// 情報がなければ匿名ログインする
final credential = await FirebaseAuth.instance.signInAnonymously();
user = credential.user;
}
} catch (e) {
return "通信に失敗しました。通信環境を確認してください。";
}
ユーザはIDを格納し、APIをコールします。
バックエンドは以下のように検証します。
export const geminiGateway = onCall(
async (request: any) => {
try {
const uid = request.auth?.uid;
if (!uid) {
throw new HttpsError("unauthenticated", "ログインしてください");
}
これにより先ほどのリクエスト数の制御が、匿名ユーザIDごとに可能となるわけです。
しかし、この匿名IDはアプリをアンインストールする度に削除されます。
つまりこのままですと、リクエスト制御にかかったユーザはアプリを再インストールすることで制御から逃れることができます。
この対策として、デバイス固有情報を組み合わせた仕組みを導入しています。
詳細はセキュリティ観点から割愛しますが、単純な再インストールでは制限を回避できない構成としています。
セキュアなAPI呼び出し(App Check)
再インストールを除いても、匿名ユーザはセキュリティ的に脆い構成となります。
Cloud FunctionsのAPIキーを知っているユーザであれば誰でも匿名ログイン→APIアクセスを乱発することが可能になってしまいます。
そこを防御するために、Firebase App Checkを使用します。
これはFirebaseに対するアクセスが、正規の端末を使用したものであり、かつGoogle Playにてインストールされた正式のアプリから発生していることを検証する仕組みです。
これにより改造されたアプリ、海賊版のアプリ、改造されたデバイス、エミュレータ等からのアクセスを防ぐことができます。
例えばクライアント側のFlutterからは、以下のような記載をすることで検証用のトークンが発行されます。
await FirebaseAppCheck.instance.activate(
androidProvider: AndroidProvider.playIntegrity, // Android用
appleProvider: AppleProvider.deviceCheck, // IOS用
);
クライアントとしての処理はこれだけで、後はApp Checkのことは気にせずAPIアクセスします。
そして、バックエンドで検証します。
export const geminiGateway = onCall(
{
enforceAppCheck: true
},
呼び出される関数の先頭部分ですが、先ほどのコードから少し追加されているのが分かります。
「enforceAppCheck: true」が追加されてますね。
これだけの記載でApp Checkトークンの検証が、Firebaseの仕組みによって自動で行われます。
その他には、もちろんFirebase App CheckのUI側で証明書を登録したりするなどの作業が必要となります。
※ App Checkは完全な防御策ではありませんが、スクリプトによる乱発や改造アプリからのアクセス抑止には非常に有効です。
プロンプト設計
クライアント側
まずはクライアントが送信するデータですが、こちらはアプリが解析済みの麻雀に関するデータをJSON形式の文字列として送信します。
手牌構成の情報は全ケース共通とし、アガリ時は点数情報、聴牌時は待ち牌情報等、各ケースごとに異なる情報もあります。
{
"手牌(面前)": ["一筒", "二筒", "三筒", "一索" "二索", "三索", "東", "東"],
"手牌(チー)": [["一萬", "二萬", "三萬"]],
"手牌(ポン)": [["南", "南", "南"]],
"手牌(暗槓)": [],
"手牌(明槓)": [],
"和了牌": "東",
"ロンまたはツモ": "ロン",
"場風": "南",
"自風": "東",
"本場": 0,
"翻数": 3,
"符数": 40,
"点数": 7700,
"役一覧": ["三色同順","混全帯么九","場風牌"],
"ドラ数": 0
}
このようなデータをユーザクエリとして付与し、APIを発行します。
final result = await FirebaseFunctions.instance
.httpsCallableFromUrl(
'https://asia-northeast1-mahjong-supporter-ai.cloudfunctions.net/geminiGateway')
.call(<String, dynamic>{
'prompt': prompt, // これがJSONデータが入ったユーザクエリ
'callType': type // これはクエリのタイプ(アガリ / 聴牌 / 打牌選択中)
});
バックエンド側
以下が全ての種類の質問の前段に必ず付与されるシステムプロンプト冒頭部分になります。
const SYSTEM_PROMPT_COMMON = `
【システム設定】
あなたは麻雀上級者AI。実戦的かつ具体的に助言する。
出力は100文字程度、最大200文字以内。
各行「・」から始まる箇条書き形式。空行禁止。
簡潔に淡々と述べる(キャラクター指定時を除く)。
ナレーション・進行説明・メタ発言(以下の通り、〜について説明する、割愛する等)禁止。
【ユーザデータ】のみを根拠に手牌と場況を正確に把握し、推測は最小限にする。
【具体的な指示】
`.trim();
そして以下が、聴牌時の「立直はすべき?」に対して発行される質問固有のシステムプロンプトです。
const SYSTEM_PROMPT_TEMPAI_REACH = `
ユーザプロンプトの聴牌時のJSON(手牌・鳴き・ドラ・場況、待ち牌、期待点数等)をもとに手牌と場況を正確に把握する。
鳴きがある場合は立直不可と明示する。
門前の場合はダマテンと立直を比較し、箇条書きで整理する。
例:
・ダマ : タンヤオのみ、1000点、待ち4枚
・立直 : リーチ+裏ドラ期待、打点上昇、待ち4枚
・終盤で放銃リスク高
打点・待ち枚数・場況を軸に判断する。
`.trim();
これに先ほどのユーザが投げてきたクエリを足し合わせて、Gemini APIをコールします。
import { GoogleGenAI } from "@google/genai";
import { defineSecret } from "firebase-functions/params";
// FirebaseシークレットマネージャーのSecretを扱う
const apiKey = defineSecret("GEMINI_API_KEY");
export const geminiGateway = onCall(
{
secrets: [apiKey] // Secretの入った変数を使う
},
async (request: any) => {
...
// Gemini API
const ai = new GoogleGenAI({ apiKey: apiKey.value() });
// APIをコールする
const response = await ai.models.generateContent({
model: modelId,
contents: [{ parts: [{ text: fullPrompt }] }],
});
const text = response.text;
...
}
プロンプト設計のポイント
- ユーザが実戦中に長々と文章を見ることができない状況を考慮し、箇条書きや「だ・である」調等、端的で整理された文章になるよう心掛けました。またクォータ消費も少なくなるため、一石二鳥です!
各行「・」から始まる箇条書き形式。空行禁止。 簡潔に淡々と述べる(キャラクター指定時を除く)。 - 麻雀の推論や思考系はどうしても誤った情報が入ることが多くなります。最大限それを避けたい思いで、事実から判断する旨、まずは状況整理する旨、推測は抑える旨を記載しました。
【ユーザデータ】のみを根拠に手牌と場況を正確に把握し、推測は最小限にする。 ユーザプロンプトの聴牌時のJSON(手牌・鳴き・ドラ・場況、待ち牌、期待点数等)をもとに手牌と場況を正確に把握する。
最後に
オンデバイスAIと生成AIの役割分担は、今後のAIアプリ設計の一つの形だと考えています。
生成AIにすべて任せるのではなく、リアルタイム処理は特化モデル、判断や言語化は生成AI、と責務を分離することで、UXとコストの両立が可能になりました。
また、プロンプトのところは生成AIに親身に相談してもらいながら試行錯誤しましたが、Gemini 2.5 Flashですと、手牌や場況によってはトンチンカンなことを言うこともあります。
より良いプロンプト案を教示いただける方いらっしゃいましたら、連絡お待ちしております!
試しに一度だけGemini 2.5 Proに変えてみたところ、段違いに良い助言をくれるようになりました。
金額が5倍くらい跳ね上がるので本アプリでは控えましたが、例えば知名度や需要の大きいアプリですと、ハイグレードモデル+制限緩和の月額プレミアムプランのユーザを増やし、元を取りつつ利益にも繋げるのはビジネスとして良い戦略だと思います。
実戦での使用感や応答速度は、ぜひ実際に触って体験していただけると嬉しいです。
AIの助言のクセも含めて、ぜひ体験してみてください!



