第1章
FlutterとFirebaseの役割と通信の流れ
もりアプリの実装をたどり、画面・認証・対戦データの同期・サーバー処理の分担を理解します。
この章で学ぶこと
- FlutterとFirebaseが、それぞれどこで動き、何を担当するか
- カードを出したとき、相手の画面にどう伝わるか
- データベースへの読み書きと、Cloud Functionsの呼び出しの違い
- 簡易版を作るときに、どこから再現すればよいか
この章は、2026年9月29日に確認したmori_gameの実装をもとにしています。Flutter側はDart、Cloud Functions側はTypeScriptで書かれています。
まず全体像をつかもう
Flutterは各プレイヤーの端末で画面と操作を担当し、Firebaseはログイン、共有データの保存・配信、サーバー側の処理を担当します。 Firebaseは一つのデータベースの名前ではなく、複数のサービスのまとまりです。
| 動く場所・サービス | もりアプリでの役割 | 具体例 |
|---|---|---|
| Flutter(スマホ・ブラウザー) | 画面表示、入力、ローカルのゲーム判定、通信 | 手札を描く、出せるカードか判定する、更新を送る |
| Firebase Authentication | アカウントの登録・ログイン、利用者の識別 | ログインした利用者に対応するuidを取得する |
| Firebase Realtime Database | 対戦状態などを保存し、変更を購読中の端末へ伝える | 場のカード、手札、順番、ルーム一覧を共有する |
| Cloud Functions for Firebase | サーバー側でプログラムを実行する | Botの進行、レート確定、ルームの整理 |
| Firebase Hosting | Web版のファイルを配信する | build/webのアプリをブラウザーに届ける |
この実装で対戦データに使うのはRealtime Databaseです。Cloud FirestoreやCloud Storageは使っていません。HostingはWeb版の配信を担当し、対戦中の状態共有はRealtime Databaseが担当します。
Realtime DatabaseとCloud Functionsの関係を、実際のデータパス・フィールド名・関数名でまとめました。中央が保存データ、右がサーバー処理、左がFlutterです。A→B→Cの順に見てください。
- A:対戦中。 Flutterが
fieldやplayerCardsを更新し、各端末がonValueで受信します。onRoomWrittenはルームの変更を受けてサマリー同期やBot進行の準備を行います。 - B:精算要求。 Flutterが
settlementRequested: trueを書き込むと、onRoomSettlementRequestedがsettleRoomSeries()を呼びます。 - C:結果の保存。 Functionsが
ratings/{playerId}とルームの確定結果を書き戻します。FlutterのwaitForSeriesSettlement()はget()を繰り返し、seriesRatingAppliedやsettlementErrorを確認します。
図のrooms/{roomId}は、段階ごとに分けて描いた同じルームです。onValueはFlutterがデータを購読するAPI、onValueWrittenはFunctions側でデータの変更を受けるトリガーです。認証とWeb配信は、この図では省略しています。
端末Aが端末Bへ直接カード情報を送るのではなく、両方の端末が同じルームのデータを見ています。また、通常のカード操作は、毎回Cloud Functionsに依頼する方式ではなく、Flutterからデータベースへ直接書き込む方式です。
起動とログインでは何が起きる?
lib/main.dartのinitializeApp()でFirebase.initializeApp()を呼び、接続先を初期化します。lib/services/firebase_emulator_config.dartで、ローカル開発ならエミュレーターへ接続先を切り替えます。lib/features/auth/app_gate.dartがauthStateChanges()で認証状態を監視し、ログイン画面かロビーを表示します。- ログイン画面の入力は
lib/services/firebase_auth_service.dartを通してAuthenticationへ送られます。
もりアプリでは、入力したユーザーIDを内部でユーザーID@mori-game.localという形式に変換し、メールアドレス・パスワード認証を利用しています。入力したIDと、Authenticationが発行するuidは別物です。対戦中のプレイヤーの識別にはuidが使われます。
Authenticationは「誰としてログインしているか」を扱います。「その人がどのデータを読めるか・書けるか」は、後述するデータベースのルールで判断します。
どんなデータを共有している?
Realtime Databaseには、パスで指定できる木構造のデータが保存されます。たとえばrooms/1234は、ルームIDが1234の対戦状態です。
| パス | 主な内容 | 利用場面 |
|---|---|---|
rooms/{roomId} |
参加者、場、山札、各プレイヤーの手札、順番、対戦の進行状態 | 対戦画面 |
roomSummaries/{roomId} |
人数や進行状態などの軽量なルーム情報 | ロビーのルーム一覧 |
users/{uid} |
プレイヤー名など | プロフィール |
ratings/{uid} |
レートや成績、表示名 | ランキング |
matchRecords/{recordId} |
対戦のメタ情報や操作イベント | リプレイ |
matchRecordSummaries/{recordId} |
対戦記録の一覧用情報 | リプレイ一覧 |
appMeta |
アプリの更新確認用情報 | バージョン確認 |
ルームでは、fieldが場のカード、playerCardsが各プレイヤーの手札、playerHandsが手札枚数、currentTurnIndexが順番を表します。Flutterは受け取った値を使い、自分の手札や相手の枚数を描きます。
ロビーではrooms全体ではなくroomSummariesを購読するため、一覧表示に不要な手札や山札まで受け取らずに済みます。Flutterがサマリーを更新する処理に加え、Cloud FunctionsのonRoomWrittenにも同期処理があります。ルーム本体とサマリーは別々に更新されるため、一瞬で完全にそろうことを前提にはしません。
カードを出してから相手に届くまで
人間のプレイヤーがカードを出す流れを、lib/features/game/game_room_page.dartとlib/services/firebase_db.dartで追ってみましょう。
- 端末Aでカードをタップする。 Flutterが現在の順番やカードの条件を確認します。
GameRules.canPlayNormal()などの判定も端末側で行います。 - 更新する値を作る。 場のカード、自分の手札・枚数、次の順番、場の履歴などを組み立てます。
- Realtime Databaseへ書き込む。
FirebaseDB.updateGameStatus()からルームのupdate()を呼びます。 - データベースがルールを評価する。 許可された更新が保存され、同じパスを購読する端末に変更が伝わります。
- 端末A・Bが状態を受け取る。 対戦画面は
roomStream.listen(_onData)で変化を受け取り、画面の状態を更新します。 - 各端末で描画・演出する。 相手の場や手札枚数が変わります。画像や効果音の再生はFlutter側の仕事です。
通信部分の入口は、次の短い実装に表れています。
// lib/services/firebase_db.dartから抜粋
_roomRef = FirebaseDatabase.instance.ref('rooms/$roomId');
Stream<DatabaseEvent> get roomStream => _roomRef.onValue;
Future<void> updateGameStatus(Map<String, dynamic> updates) =>
_updateRoomAndSummary(updates);
onValueは、購読開始時とデータ変更時に値を受け取る仕組みです。画面のスクリーンショットを送るのではなく、カードの数字やマークなどの状態データを送り、それぞれの端末で同じ場を描き直します。
ただし、送信元ではローカルの状態変更やSDKの通知が先に画面へ反映されることがあります。「画面が変わった」ことと「サーバーへの保存が成功した」ことは区別しましょう。APIの詳細はFirebase公式の読み書きガイドで確認できます。
読み書きの方法を使い分ける
| API | 役割 | 実装での例 |
|---|---|---|
get() |
一度だけ現在の値を取得する | ルームのスナップショット確認 |
onValue |
最初の値とその後の変更を受け取る | 対戦状態・ルーム一覧・ランキングの購読 |
set() |
指定した場所の値を置き換える | ルームサマリーの初期保存 |
update() |
指定した項目をまとめて変更する | 場・手札・順番の更新 |
runTransaction() |
現在の値を確認し、競合時には再評価して更新する | 空いているIDへのルーム作成、誤もりの先着確定 |
onDisconnect() |
接続が切れたときの処理を事前に登録する | 接続中の印を削除する |
一回のupdate()で複数項目を更新することと、他の端末の操作との競合を防ぐことは別です。現在の実装でも、通常のカード操作と、runTransaction()を使う処理は分かれています。「Realtime Databaseを使えば、同時操作の競合も自動で解決する」とは考えないようにしましょう。
Cloud Functionsはいつ動く?
Cloud FunctionsはFirebase側で動くプログラムです。入口はfunctions/src/index.tsにまとまっており、主に次の起動方法があります。
| 起動方法 | もりアプリでの例 | Flutterとの関係 |
|---|---|---|
| データベースの変更 | onRoomWritten、onRoomSettlementRequested |
Flutterなどが書いた値の変化を契機に動く |
| タスクキュー | botActionTask |
サーバー側で予定したBot操作を実行する |
| 定期実行 | scheduledRoomMaintenanceSweep、scheduledRoomCleanup |
進行の復旧や不要データの整理を行う |
| Callable関数 | submitContact、deleteAccount |
Flutterから明示的に呼び出し、結果を待つ |
例1:対戦終了後のレート確定
FlutterのrequestSeriesSettlement()は、ルームにsettlementRequested: trueを書き込みます。その変更をonRoomSettlementRequestedが検知し、settle_room.tsのsettleRoomSeries()が終了条件などを確認して、レートと確定結果を保存します。Flutterは確定結果を待ち、表示を更新します。
Flutter → DBに精算要求を保存
↓ 変更を検知
Cloud Functions → DBにレート・確定結果を保存
↓ 変更通知
Flutterで結果表示
これは、関数を直接呼んで戻り値を受け取る方式ではありません。データベースを介した要求と結果の受け渡しです。
例2:お問い合わせ・アカウント削除
contact_service.dartはhttpsCallable('submitContact').call(...)、account_deletion_service.dartはhttpsCallable('deleteAccount').call<void>()を使います。こちらはFlutterから関数に処理を依頼し、応答を受け取る方式です。
例3:切断しても進行を管理する
FirebaseDB.registerPlayerPresence()では、接続中を表すpresence/{uid}と、離脱を表すafkPlayerIds/{uid}を扱います。切断時に前者を削除し、後者へ時刻を書き込む処理をonDisconnect()で事前登録します。
Cloud Functionsは接続状態の変化を受けてルームの進行を管理し、Botや離脱者の代走などを処理します。Flutter側も.info/connectedを監視し、再接続時には接続情報と切断時処理を登録し直します。端末を閉じたあとに、その端末のFlutterが処理し続けるわけではありません。
現在の構成には、Flutter側にもタイマー・Bot関連処理・もり確定の補完処理があります。したがって、ゲームの判定がすべてサーバー側に集約されている構成ではありません。 どちらが担当するかは処理ごとにコードを確認します。
認証・ルール・ゲーム判定を分けて考える
database.rules.jsonは、クライアントからのデータベースアクセスを許可する条件や、保存値の検証条件を定義します。たとえばusers/{uid}では本人だけが読み取れ、プレイヤー名には文字数の検証があります。ratingsのレート本体はクライアントからの書き込みを禁止し、本人の表示名などだけを更新可能にしています。
一方、現在のroomsは読み取りが公開され、ルームへの書き込みも認証済み利用者に広く許可されています。手札を画面に表示しないことは、データへのアクセスを制限することと同じではありません。
また、Realtime Databaseの.read・.writeは親の許可が子にも及びます。現在のルールでは、ルームの親で書き込みを許可しているため、子のseriesRatingAppliedなどにある.write: falseだけでは書き込みを禁止できません。この性質はFirebase公式のルール構文ガイドで確認できます。
再現時は、「Flutterで操作を制限する」「データベースのルールでアクセスを制限する」「サーバーで操作の正当性を検証する」を分けて考えましょう。この章で説明しているのは現状の役割分担であり、すべての操作がサーバーで検証されているという意味ではありません。
エミュレーターで通信を観察しよう
環境構築を済ませたmori_gameのディレクトリで、ターミナルを二つ使います。初回の準備はリポジトリのREADMEを参照してください。
ターミナル1でFirebaseのローカル環境を起動します。
npm run emulators
ターミナル2でFlutterを起動します。
flutter run -d chrome
この実装では、明示的な指定がなければデバッグビルドはエミュレーターに接続します。コンソールの「Firebase Local Emulator Suite に接続しました」を確認し、Emulator UIを開きましょう。
- アカウントを作り、Authenticationに利用者が追加されることを確認する。
- ルームを作り、Databaseの
roomsとroomSummariesを見る。 - 別のブラウザーセッションで別アカウントを使って参加し、両方の画面を並べる。
- カードを出し、
field・playerCards・currentTurnIndexと相手の画面が変わることを確認する。 - 一方を閉じ、切断検知後に
presenceとafkPlayerIdsが変わる様子を見る。 - 規定の試合を終え、
settlementRequested、Functionsのログ、ratingsの変化を追う。
簡易版を作るなら、この順番で
- Flutterだけで画面を作る。 固定の手札と場を表示し、ボタンで場が変わるところまで作ります。
- Authenticationをつなぐ。 ログインして
uidを取得します。 - 一つのルームを共有する。 場のカードを
update()で保存し、もう一方の画面でonValueを購読します。 - 対戦状態を増やす。 参加者、手札、順番を追加し、同時操作と書き込み権限を考えます。
- サーバー側の処理を追加する。 まず終了後の集計をCloud Functionsで行い、その後に切断対応やBotを追加します。
最初の目標は「片方の画面で操作すると、共有データが変わり、もう片方にも反映される」ことです。この往復を理解すると、対戦・観戦・ランキングにも同じ仕組みが使われていることが見えてきます。
実装を読むための案内
以下のパスはmori_gameリポジトリのルートからの相対パスです。
| 知りたいこと | 読むファイル |
|---|---|
| Firebaseの初期化 | lib/main.dart |
| 登録・ログイン | lib/services/firebase_auth_service.dart、lib/features/auth/app_gate.dart |
| ルーム一覧の購読 | lib/features/entrance/entrance_page.dart |
| カード操作・状態の受信 | lib/features/game/game_room_page.dart、lib/logic/game_rules.dart |
| ルームの読み書き・切断処理 | lib/services/firebase_db.dart |
| サーバー処理の入口 | functions/src/index.ts |
| レート確定・進行管理 | functions/src/settle_room.ts、functions/src/room_steward.ts |
| アクセス権限 | database.rules.json |
| ローカル接続・配信の設定 | lib/services/firebase_emulator_config.dart、firebase.json |
理解を確認しよう
- 相手の端末に送るのは画面の画像でしょうか、それとも状態データでしょうか?
onValueで購読する方法と、get()で取得する方法はどう違うでしょうか?- レート確定の要求とお問い合わせ送信では、Cloud Functionsの起動方法がどう違うでしょうか?
- Flutterでボタンを押せなくするだけでは、なぜデータの書き換えを防げないのでしょうか?