40時間でつくったオンラインボドゲの話

# 概要

制作時間 40 時間ほどの以下の web app の制作過程について述べる

https://game-platform-opal.vercel.app/

エンジニアが興味を引くかもしれない点
- :eye: 動機通信を socket.io 使わずに実装した
- :eye: firebase の function を(ほぼ)使わずに実装
- :eye: これくらいのアプリの規模で firestore の通信量はどれくらいになるのか

<details><summary>ゲームのルール説明(要件定義)</summary>

# ゲームルール

## 👤 アクター（参加者）

- 最低 2 人が必要。
- すべてのアクターは等しい関係性を持つ（特別な役職はない）。

## 🎭 ゲームのフェーズ

### 1. 番号配布フェーズ

- 各アクターには 1 から 100 までのランダムな整数（マイナンバー）が 1 つずつ与えられる。
  - マイナンバーはアクター間で重複しない。
  - 他のアクターのマイナンバーは閲覧できない。
- 全員が閲覧できる短い文章（お題）が開示される。

### 2. キーワード思案フェーズ

- 各アクターは、お題に沿った任意の単語（キーワード）を 1 つ考える。
  - キーワードはお題との関連性に基づいて、1（最も関係が弱い）から 100（最も関係が強い）の範囲で評価されるべき。
  - アクターは自分のマイナンバーに相当するキーワードを他のアクターと相談なしに考える。

### 3. 順番話し合いフェーズ

- アクターは自分のキーワードを開示し、話し合いを通じてキーワードに順番をつける。
  - 順番はマイナンバーが昇順になるように決定する。
  - 最も小さいマイナンバーに相当するキーワードを特定する。

### 4. 数字開示フェーズ

- 最初に決定したキーワードのアクターがマイナンバーを開示する。
  - 最初の数字開示の場合、残りのキーワードを使って順番話し合いフェーズに戻る。
  - それ以外の場合、前回の数字開示フェーズで開示した数字と比較する
    - 今回の数字が大きければ成功、小さければ失敗としてカウントし、残りのキーワードを使って順番話し合いフェーズに戻る。
  - すべてのキーワードを使い切った場合、ゲーム終了。

## 🔍 ゲームの終了条件

- 数字開示フェーズで、全てのキーワード・マイナンバーを開示したとき

## ⚖️ ゲームの勝利条件

- 全てのキーワード・マイナンバーを開示したとき
  - 一度でも失敗があれば、アクター全員の敗北となる。
  - すべての数字開示フェーズで成功した場合、アクター全員の勝利となる。
  - すなわち、アクター全員が勝利か敗北のどちらかになる。


</details>


## この記事で話すこと

- このアプリのインフラ構成・技術スタック
- これくらいのアプリをこんな構成で組んだら firestore の利用量これくらいになるよ

## :eye_in_speech_bubble:  この記事の目的

- アプリの宣伝
  - アイスブレイクにどうですか !!!
- エンジニアのアウトプットとして

# :chart_with_upwards_trend: 技術スタック

## インフラ

- hosting
    - vercel
- DB
    - firestore
- auth
    - firebase auth
- 言語
    - js, ts (nodejs)
- backend
    - cloud function

## package.json(抜粋)

```json
  "dependencies": {
    "@mantine/core": "^7.2.2",
    "@vercel/analytics": "^1.1.1",
    "firebase": "^10.6.0",
    "firebase-admin": "^12.0.0",
    "framer-motion": "^10.16.16",
    "lodash": "^4.17.21",
    "next": "14.0.2",
    "react": "^18",
    "react-dom": "^18",
    "react-firebase-hooks": "^5.1.1",
    "zod": "^3.22.4",
    ...
  },
  "devDependencies": {
    "@biomejs/biome": "1.3.3",
    "@storybook/nextjs": "^7.6.6",
    "@storybook/react": "^7.6.6",
    "jest": "^29.7.0",
    "storybook": "^7.6.6",
    "ts-jest": "^29.1.1",
    "typescript": "^5",
    ...
  }
```

:::note info
特にお世話になっている library
:::

https://mantine.dev/

- :thumbsup: 種類豊富で, どれも 「プロダクトに組み込む可能性ありそう」 って思えるようなコンポーネントばかりの UI. つまりは無駄がない
- :thumbsup:  addon で提供しているコンポーネントもあり、それらも１つの theme system で統括的に制御できるようになっている。
- :star2: 完成度高い library でおすすめ。やや重たいですが :sweat: 

https://www.npmjs.com/package/react-firebase-hooks

- :thumbsup:  firestore の realtimeDB としての機能を hooks 化して利用しやすくしたもの.
- :thumbsup: 本来 firestore は `onSnapshot` や `useEffect` を駆使して state を管理する必要があるが、それらを wrap した関数を提供してくれている.

# :bookmark: ボドゲの仕様-要件について

有名なオンラインボドゲの要件と比較しながら説明する。 ここでは 『じゃんたま』 をとりあげる.

https://mahjongsoul.com/

||カチカンポーカー|じゃんたま|
|-|-|-|
|プレイ人数|2人以上|3~4人|
|要同期通信| :o: | :o: |
|自分の手札の視認性| 自分のみ | 自分のみ |
|**ゲーム形式**| 協力 | 対戦 |
|プレイヤーの関係|リア友|オンライン上の不特定のユーザー|
|アカウント|不要|必要|
|CPU bot|不要|必要|

## 要同期通信

このオンラインボドゲの根底には「対面で楽しむボドゲをオンラインでも簡単に楽しみたい」ことがある。
この要件を満たすには, 動機通信は絶対必要となる.
筆者はこれまでに同期通信のサービスで形あるものは作っていなかったので, 同期通信のサービスとしては処女作となる。

:::note info
要件的に, 高速な同期通信が必要
:::


## 対戦形式

対戦型ではなく協力型(全員が勝ち, もしくは全員が負け)という要件であるため, セキュリティに関して考える要素が少なくなる.

例えば, じゃんたまで相手の牌( ≒ 手札)が解析等でバレてしまったり、山( ≒ 山札)がバレてしまってはゲームが崩壊してしまう.

一方で, **対戦相手が存在しない**カチカンポーカーでは, 仮に解析でバレたとしても, 失うものはボードゲームの楽しさであるため, ユーザーがわざわざそこまでして解析する利点がない。そもそも口頭で伝えれば容易にバレてしまうため, むしろうっかりバレたりしないようにするまである.

:::note info
対戦型ではなく協力型にすることで、実装難易度がグッと下がっている
:::

## アカウント

アプリのシステムを作成する上で認証は重要かつ複雑になりやすいファクターである. じゃんたまは言わずもがな,カチカンポーカーも、「自分の手札」を識別するために、ユーザーごとの識別子(= uid) が必要になる.
ただし
- webで気楽に
 
のコンセプトを守るためには, 「アカウント登録 = email 認証など」をさせたくない.

:::note info
ユーザーには「ユーザー登録」の煩わしさを感じさせずに, システム的にはユーザーごとに一意な uid を取得する必要がある
:::

# :wrench: 実装

## 要件定義の方法

おおまかには人間が定義し、最終的なブラッシュアップは :robot: ChatGPT に依頼した。
その際

- 誰が見ても同じ解釈をする文章かどうか

というのを重点的に見直した。最終的なアウトプットは[この記事上部](#ゲームルール)にて記載している.

## 動機通信の実装

エンジニアが一番気になるところかもしれない. socket.io を使わなかった理由としては

- app router(NextJS) と socket.io が両立できない(らしい)

点である. (正直これが可能なのなら socket.io でよかった)

> https://www.zenryoku-kun.com/new-post/nextjs-socketio
> Next.js13で導入されたApp RouterのWebサーバ機能では、Socket.IOを使うことは出来ません

そこで、 firebase - firestore の 双方向通信機能を使うことにした.

https://firebase.google.com/products/firestore?hl=ja#:~:text=%E3%82%B0%E3%83%AD%E3%83%BC%E3%83%90%E3%83%AB%20%E3%82%A2%E3%83%97%E3%83%AA%E7%94%A8%E3%81%AB%E6%A7%8B%E7%AF%89,%E3%81%A7%E3%81%8D%E3%82%8B%20NoSQL%20%E3%83%89%E3%82%AD%E3%83%A5%E3%83%A1%E3%83%B3%E3%83%88%20%E3%83%87%E3%83%BC%E3%82%BF%E3%83%99%E3%83%BC%E3%82%B9%E3%81%A7%E3%81%99%E3%80%82

つまり、ユーザー間で共通してもつ値全てを DB で管理する, ということになる.

具体的には

- 各ユーザーの数値, 言葉
- お題

といったゲームシステムに関わるものから

- どのカードを選択しているか
- ユーザーは言葉を記入し終えたか

といったような小さな情報をも DB へ保存し, 各ユーザーはその変更を firestore から受け取り画面を更新する.

---

この構成の最大の利点は

- ユーザーごとに異なる計算結果が存在することが無い

点にある。

例えばエアーホッケーのような通信速度が命のようなサービスの場合、可能な限りサーバーの計算量は減らしたくなる. そのためクライアント側で衝突計算等をやってしまいたくなるが「クライアントごとに計算結果がことなる」ということがそれなりに起こりうる。
他にも, FPS の代表として「APEX」では、たまにユーザー間で「弾抜け」＝「絶対に弾があったようにあるユーザー視点では見えるが, 計算上は当たっていないと判定されること」が発生する。

一方で「データの正義は firestore　である」とすることで、クライアントごとに差が生じることはなくなる。

:::note info
クライアント共通で表示するすべてのデータを firestore で管理することで,　バグの生じにくい同期通信システムを構築できる
:::

---

一方で、このシステムの欠点は

- firestore の書き込み・読み取り回数が肥大化する

点である.

早い話

- カードをクリックする

だけで、ユーザー人数分の読み取りが発生する.読み取り回数がかなり多くなるのは想像に容易い.
次に見せるのは, ユーザー数のざっくりな推移である

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1714399/42ec5e7e-61e7-f059-7e8e-a0505e55d1a4.png)
<caption>↑ページに訪れた人数</caption>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1714399/7e3a946b-11e5-37de-00f0-f67cf94c0999.png)
<caption>↑firestore の書き込み・読み取り回数</caption>

firestore は ５万読み取り/日 が無料枠なので、 多めに見積もっても

- ユーザー : 100人で ５万読み取り/日 を超える

ことになる.
(あるグループが何回戦するかで話は変わってくるが、統計から見積もった限りこれくらい)
多少は firestore の読み取りの ロジック に無駄があるだろうが、それでもせいぜい現状から 1/2 程度だと思われる.
DB 設計を見直すことで、 1/5 くらいにすることは可能かもしれない.ただし、あまり読み取り回数のことばかり意識すると、今度は仕様変更がしづらくなりかねない.

> firebase 料金表
> https://firebase.google.com/pricing?hl=ja

今は無料枠超えることはむしろ嬉しい悲鳴であるので構わないが、このシステム構成を真似て事業展開しようものなら破産しやすいので, 読み取り回数が多くなりすぎないように注意が必要である.
 
:::note alert
firestore の書き込み・読み取り回数が肥大化する
:::

## cloud function を用いた箇所

このシステムで cloud function を使っているのは１箇所で, presence の実装である.

presence とは、

- ユーザーが現在このページを閲覧中・プレイ中

といったような、「google slide」 や 「notion」 などに実装されている機能である.
この実装ばかりはどうしても cloud function を使わねば実装できない

> google 公式 : presence の実装
> https://firebase.google.com/docs/firestore/solutions/presence?hl=ja

つまり,

- 各プレイヤーの数字の決定
- ユーザーが言葉を決定する
- カードを選択する

といったようなイベントは cloud function を使っておらず、すべて firestore のデータ変更を 各クライアントが subscrbe ( = onSnapshot関数) することで実装している.

一般にゲームにはホストなるものが存在し、サーバーでしか処理し得ないものがある(じゃんたまでいう山の決定や、初期の手牌の決定など)が、すべてクライアントで処理している.

経緯として

- 「さすがにこの処理はサーバーで処理しないとまずい」という内容が出る限界までクライアントで実装してみる

として実装を進めたところ、最後までできてしまった.
これも、

- 対戦相手がいない

という要件がもたらす恩恵である. 悪さをしても意味がないのだ.

## アカウントの実装

アカウントの実装は実は至極単純なもので, 名前の入力・アバターを決定した際に firebase - authentication の匿名認証でアカウントを作成している.

これによりユーザーには uid が固有に振られ、これによって画面に表示する カード の分岐処理を実装している。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1714399/1511a7e1-45e5-ecf1-dd98-cad1e5183cb4.png)

## アプリケーションアーキテクチャ

基本的な NextJS のフォルダ構成に加えて, firestore とのやりとりを実装するために、以下のような構成で実装した.

```bash
./src
├── hooks
│   ├── repository
│   │   ├── index.ts
│   │   ├── useCloudAvatarImages.ts
│   │   ...
│   └── useSceneState
│       ├── useDecidingTopic.ts
│       ...
├── models
│   ├── AvatarImage.ts
│   ...
...
```

- `./src/models`
    - firestore に格納するデータモデルを定義するところ. firestore はスキーマレスなので, 読み取った値が想定通りでない可能性がある。 それもあり 将来的にデータの validation が必要になることを見据え, データの定義は zod を用いて定義している.
- `./src/hooks/repository`
    - firestore の あるデータモデルとの通信を担うレイヤー. 具体的には firestore の １つの `ref` に 1つの repository が対応する.
    - ex: avatarImages collection との通信はすべて `useCloudAvatarImages` を介して行われる.
- `./src/hooks/useSceneState`
   - 各シーン(お題決定, 言葉考え中, ...) で使われる hooks 

> https://www.npmjs.com/package/zod

# まとめ

- 動機通信を socket.io 使わずに実装した
- firebase の function を(ほぼ)使わずに実装
- これくらいのアプリの規模で firestore の通信量はどれくらいになるのかの紹介

:::note info
ハッカソンとか社内のアイスブレイクとかで使ってみてね
:::