第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の順に見てください。

もりアプリのデータと処理の対応図。Flutterがroomsの状態を更新し、Cloud Functionsが変更を検知して処理する。settlementRequestedからレート確定、結果保存までの流れ。

図を大きく開く(変数名・関数名を確認する)

  • 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からデータベースへ直接書き込む方式です。

起動とログインでは何が起きる?

  1. lib/main.dartのinitializeApp()でFirebase.initializeApp()を呼び、接続先を初期化します。
  2. lib/services/firebase_emulator_config.dartで、ローカル開発ならエミュレーターへ接続先を切り替えます。
  3. lib/features/auth/app_gate.dartがauthStateChanges()で認証状態を監視し、ログイン画面かロビーを表示します。
  4. ログイン画面の入力は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で追ってみましょう。

  1. 端末Aでカードをタップする。 Flutterが現在の順番やカードの条件を確認します。GameRules.canPlayNormal()などの判定も端末側で行います。
  2. 更新する値を作る。 場のカード、自分の手札・枚数、次の順番、場の履歴などを組み立てます。
  3. Realtime Databaseへ書き込む。 FirebaseDB.updateGameStatus()からルームのupdate()を呼びます。
  4. データベースがルールを評価する。 許可された更新が保存され、同じパスを購読する端末に変更が伝わります。
  5. 端末A・Bが状態を受け取る。 対戦画面はroomStream.listen(_onData)で変化を受け取り、画面の状態を更新します。
  6. 各端末で描画・演出する。 相手の場や手札枚数が変わります。画像や効果音の再生は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を開きましょう。

  1. アカウントを作り、Authenticationに利用者が追加されることを確認する。
  2. ルームを作り、DatabaseのroomsとroomSummariesを見る。
  3. 別のブラウザーセッションで別アカウントを使って参加し、両方の画面を並べる。
  4. カードを出し、field・playerCards・currentTurnIndexと相手の画面が変わることを確認する。
  5. 一方を閉じ、切断検知後にpresenceとafkPlayerIdsが変わる様子を見る。
  6. 規定の試合を終え、settlementRequested、Functionsのログ、ratingsの変化を追う。

簡易版を作るなら、この順番で

  1. Flutterだけで画面を作る。 固定の手札と場を表示し、ボタンで場が変わるところまで作ります。
  2. Authenticationをつなぐ。 ログインしてuidを取得します。
  3. 一つのルームを共有する。 場のカードをupdate()で保存し、もう一方の画面でonValueを購読します。
  4. 対戦状態を増やす。 参加者、手札、順番を追加し、同時操作と書き込み権限を考えます。
  5. サーバー側の処理を追加する。 まず終了後の集計を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でボタンを押せなくするだけでは、なぜデータの書き換えを防げないのでしょうか?