生成AIチャット&クイズ対戦ゲーム(ブラウザ)
チャット画面の動画
二人対戦画面の動画
これは何か
生成AIに複数のペルソナ(AIキャラ)を演じさせ、ユーザーがチャットで関係性を深めて「仲間」にし、仲間を使ってクイズ対戦できるブラウザゲームです。
- チャット:AIキャラと会話して親密度を上げる
- 仲間化:一定条件でキャラを仲間にできる
- バトル(クイズ):人間関係の面倒なシチュエーションを問題として出し、仲間キャラに回答させる
- 採点:回答の適切さをさらに生成AIで採点
- 2人対戦:WebSocketでリアルタイム対戦
- ペルソナ/クイズはDBで管理し、一覧・参照できるようにしています
技術スタック
- Frontend: React + TypeScript
- Backend: Python + FastAPI
- DB: MySQL
- Auth: Amazon Cognito(Google認証)
- Realtime: WebSocket
- LLM: OpenAI(ストリーミング / JSON mode)
- Migration: dbmate(up/downのみのシンプル運用)
- ORM/Model: SQLModel(Pydanticベース)
- Codegen: SQLAlchemy automap + 自作スクリプト(Pydanticモデル/pyi自動生成)
- UI: AmplifyのChat UIを改修して流用
※開発の流れ(ざっくり)
最初はAmplifyテンプレから立ち上げて、Amplifyを導入できそうかを試作しました(その過程でChat UI改修の当たりも一部調査)。
方向性が見えたところで、自作の「即席アプリテンプレ(単一ポート/最小構成)」に置き換えて作り直しました。
自作テンプレと、AWS上の EC2 / Cognito(Google OAuth)を構築する自作ツール(AWS SDK + GitHub Actions)を土台にして、完成版の作り込みは3週間でやり切りました。
こだわったポイント(設計と実装)
1) 最小で回るDB運用:dbmate + SQLModel + 自動生成
個人開発で辛いのは「DB定義とコードの同期が崩れること」だったので、以下を狙いました。
- migrationはシンプルに:up/downだけで運用できるdbmateを採用
- Pydanticを中心にしたい:型の一貫性のためSQLModelを採用
- モデル/型ヒントの自動生成が欲しい:SQLAlchemy automapをベースに、
- Pydanticモデルコード
- .pyi(型補完用)
を生成するスクリプトを自作
「マイナーすぎて情報が無い」「トリッキーすぎる自作は避けたい」のバランスを取り、既存の強い仕組み(automap)に寄せた自作に落としました。
2) 認証:Cognito(Google)+FastAPIでトークン検証&User自動生成(競合対策込み)
- CognitoでGoogle認証を実装
- FastAPI側でトークン検証をミドルウェア化
- 認証後、アプリ独自のUser情報をDBから取得
- なければUserレコードを新規作成
- ほぼ同時に二重実行された場合のユニークキー競合(Exception)をハンドリングして、必ずUser情報が取得できるように実装
フロント側ではログイン直後にUser情報を取得し、React Contextでアプリ全体から参照できるようにしました。
3) 2人対戦:WebSocket + ルームテーブルで状態管理(切断/解散まで)
対戦はWebSocketで双方向通信し、DB(ルームテーブル)で状態を管理しています。
- ロビー画面でルーム作成 → 対戦画面へ遷移 → WebSocket確立 → ルームレコード作成(待機)
- 参加側が待機ルームに参加 → WebSocket確立 → ルーム状態更新 → ホストへマッチ通知
- 切断時 → 相手へ離脱通知 → ルームを解散状態に更新 → ロビーへ戻す
- ヘッダのメニュー遷移などでも、WebSocketを確実に閉じてルーム解散する設計
WebSocketは画面遷移を跨ぐので、WebSocket接続もReact Contextで管理して、どこからでも正しく閉じられる状態にしました。
WebSocket認証
WebSocketはHTTPヘッダが使いにくいので、初回メッセージでCognitoトークンを送信し、サーバ側で検証してからルーム処理を許可する方式にしています。
4) Reactの二重マウント問題と、ルーム作成/参加の責務分離
実装で一番迷ったのは「いつ接続し、いつルームを書き、いつマッチさせるか」です。
- 対戦画面に入った時点で全部やるのか
- ルーム作成ボタン押下で開始するのか
- 参加ボタン押下で開始するのか
- ホスト/ゲストで処理をどう分けるのか
結局、対戦画面側で二重処理を防止した上で、処理は一元管理に寄せるのが最も事故りにくい、という判断に落ちました。
5) LLM応答のストリーミング表示と、FastAPI(asyncio)イベントループの非ブロッキング化
WebSocketは常時接続されるため、通常のHTTP APIのようにスレッドプールへ処理を投げると、スレッドを長時間占有してしまうという問題があります。
そのため、本実装ではWebSocketハンドラはasyncで実装しています。
一方、OpenAI SDKは同期かつ応答時間の長い処理であり、そのまま呼び出すと FastAPI(asyncio)のイベントループをブロックしてしまいます。
そこで本実装では、
- OpenAIのストリーム(同期ジェネレータ)をチャンク単位で別スレッドで取得
- 取得したチャンクを即座にWebSocket 経由で通知
- 次のチャンク取得も同様に別スレッドで実行
という流れを繰り返すことで、イベントループを止めることなく、LLM応答のストリーミング表示を実現しました。
6) Chat UI:Amplifyのコンポーネントを改修して流用
AmplifyのChat UIは本来Bedrock前提で、履歴や接続先も固定的でしたが、
- OpenAIに接続
- 会話履歴はFastAPI側で保存・取得
- 「仲間にする」などの独自フォームも表示
できるようにコンポーネントを改修して流用しました。
UIの作り込みより、機能検証と実装の厚みを優先する意図です。
7) 親密度スコア:OpenAI JSON mode + Pydantic型で型安全に数値化
会話の「親密度」を採点して数値化する部分では、OpenAIのJSON modeを利用し、
- Pydanticの型に沿ったJSONで返す
- 数値としてそのままDBに使える
ようにしています。
「AIの出力が文字列でブレる」を避け、型で縛って扱えるのがかなり便利でした。
目的(なぜ作ったか)
生成AIアプリは、画面を作るだけなら簡単ですが、実際には
- 認証・ユーザー管理
- 会話履歴の永続化
- ストリーミング表示
- リアルタイム対戦(WebSocket)
- 状態管理(ルーム)
- 二重実行/競合/切断の扱い
あたりで現実の難しさが出ます。
このゲームは「面白いコンセプト」を作ること以上に、生成AIを含むWebアプリの実装論点を一通り踏み抜くことを目的に作りました。